From 76ed298ca390af3e3d444e5a56a716c486669078 Mon Sep 17 00:00:00 2001 From: Ahmed Helmy Date: Wed, 11 Mar 2026 23:21:31 +0200 Subject: [PATCH 1/9] add caches with hive and add check on the internet connection. --- mobile/lib/abstract/connectivity_service.dart | 3 + mobile/lib/abstract/local_cache_service.dart | 5 + mobile/lib/exceptions/network_exception.dart | 7 + mobile/lib/main.dart | 7 + .../lib/model/announcement/announcement.dart | 9 + mobile/lib/model/assignment/assignment.dart | 12 ++ .../lib/model/grade_sheets/grade_sheet.dart | 9 + .../grade_sheets/grade_sheet_record.dart | 8 + mobile/lib/model/quiz/question.dart | 7 + mobile/lib/model/quiz/quiz.dart | 15 ++ mobile/lib/model/staff/staff.dart | 11 ++ mobile/lib/model/student/student.dart | 9 + mobile/lib/services/local_cache_service.dart | 22 +++ .../services/remote_announcement_service.dart | 48 ++++- .../services/remote_assignment_service.dart | 119 +++++++++++- .../services/remote_connectivity_service.dart | 14 ++ .../services/remote_grade_sheet_service.dart | 140 +++++++++++++- mobile/lib/services/remote_quiz_service.dart | 180 +++++++++++++++++- mobile/lib/services/remote_staff_service.dart | 83 +++++++- .../lib/services/remote_student_service.dart | 75 +++++++- .../add_announcement_screen.dart | 8 +- .../assignments/create_assignment_screen.dart | 8 +- .../assignments/update_assignment_screen.dart | 8 +- .../grade_sheets/add_grade_sheet_screen.dart | 8 +- .../add_student_grade_screen.dart | 15 ++ .../staff/profile/reset_password_screen.dart | 16 +- .../staff/profile/update_info_screen.dart | 16 ++ .../quizzes/create_edit_quiz_screen.dart | 3 + .../staff/staff/add_staff_screen.dart | 6 +- .../staff/students/add_student_screen.dart | 6 +- .../student/quiz/quiz_session_screen.dart | 4 +- mobile/pubspec.yaml | 2 - 32 files changed, 832 insertions(+), 51 deletions(-) create mode 100644 mobile/lib/abstract/connectivity_service.dart create mode 100644 mobile/lib/abstract/local_cache_service.dart create mode 100644 mobile/lib/exceptions/network_exception.dart create mode 100644 mobile/lib/services/local_cache_service.dart create mode 100644 mobile/lib/services/remote_connectivity_service.dart diff --git a/mobile/lib/abstract/connectivity_service.dart b/mobile/lib/abstract/connectivity_service.dart new file mode 100644 index 00000000..088996e3 --- /dev/null +++ b/mobile/lib/abstract/connectivity_service.dart @@ -0,0 +1,3 @@ +abstract class ConnectivityBaseService { + Future hasInternet(); +} diff --git a/mobile/lib/abstract/local_cache_service.dart b/mobile/lib/abstract/local_cache_service.dart new file mode 100644 index 00000000..c03bfd4c --- /dev/null +++ b/mobile/lib/abstract/local_cache_service.dart @@ -0,0 +1,5 @@ +abstract class LocalCacheBaseService { + Future cacheData(String boxName, dynamic data); + Future getCachedData(String boxName); + Future clearCache(String boxName); +} diff --git a/mobile/lib/exceptions/network_exception.dart b/mobile/lib/exceptions/network_exception.dart new file mode 100644 index 00000000..cca1e03b --- /dev/null +++ b/mobile/lib/exceptions/network_exception.dart @@ -0,0 +1,7 @@ +class OfflineException implements Exception { + final String message; + OfflineException([this.message = 'No internet connection. Please check your network and try again.']); + + @override + String toString() => message; +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 53672728..42609eb8 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -10,7 +10,9 @@ import 'package:yaspo_mobile/abstract/assignment_service.dart'; import 'package:yaspo_mobile/abstract/auth_service.dart'; import 'package:yaspo_mobile/abstract/credential_manager_service.dart'; import 'package:yaspo_mobile/abstract/download_csv_service.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; +import 'package:yaspo_mobile/abstract/local_cache_service.dart'; import 'package:yaspo_mobile/abstract/notification_service.dart'; import 'package:yaspo_mobile/abstract/quiz_service.dart'; import 'package:yaspo_mobile/abstract/staff_service.dart'; @@ -28,7 +30,9 @@ import 'package:yaspo_mobile/enums/user_type.dart'; import 'package:yaspo_mobile/exceptions/auth_exception.dart'; import 'package:yaspo_mobile/firebase_options.dart'; import 'package:yaspo_mobile/model/user/user.dart'; +import 'package:yaspo_mobile/services/remote_connectivity_service.dart'; import 'package:yaspo_mobile/services/hive_token_storage_service.dart'; +import 'package:yaspo_mobile/services/local_cache_service.dart'; import 'package:yaspo_mobile/services/remote_analytics_service.dart'; import 'package:yaspo_mobile/services/remote_announcement_service.dart'; import 'package:yaspo_mobile/services/remote_assignment_service.dart'; @@ -64,6 +68,9 @@ void main() async { var userCubit = UserCubit(); GetIt.I.registerSingleton(HiveTokenStorageService()); + GetIt.I.registerSingleton(HiveTokenStorageService()); + GetIt.I.registerSingleton(RemoteConnectivityService()); + GetIt.I.registerSingleton(LocalCacheService()); GetIt.I.registerSingleton(RemoteStudentService()); GetIt.I.registerSingleton(RemoteAuthService()); GetIt.I.registerSingleton(RemoteTenantService()); diff --git a/mobile/lib/model/announcement/announcement.dart b/mobile/lib/model/announcement/announcement.dart index 4f8db424..3f3e88b5 100644 --- a/mobile/lib/model/announcement/announcement.dart +++ b/mobile/lib/model/announcement/announcement.dart @@ -19,4 +19,13 @@ class Announcement { createdAt: json['created_at'] ?? '', ); } + + Map toJson() { + return { + 'id': id, + 'content': content, + 'author': authorName, + 'created_at': createdAt, + }; + } } diff --git a/mobile/lib/model/assignment/assignment.dart b/mobile/lib/model/assignment/assignment.dart index ad975c56..c5e0ec2b 100644 --- a/mobile/lib/model/assignment/assignment.dart +++ b/mobile/lib/model/assignment/assignment.dart @@ -28,4 +28,16 @@ class Assignment { submitted: json['submitted'] ?? false, ); } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'author': author, + 'max_grade': maxGrade, + 'due_date': dueDate, + 'submitted': submitted, + }; + } } diff --git a/mobile/lib/model/grade_sheets/grade_sheet.dart b/mobile/lib/model/grade_sheets/grade_sheet.dart index 3ed74b43..93787bac 100644 --- a/mobile/lib/model/grade_sheets/grade_sheet.dart +++ b/mobile/lib/model/grade_sheets/grade_sheet.dart @@ -14,4 +14,13 @@ class GradeSheet { grade: json['grade'], ); } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'visible': visible, + 'grade': grade, + }; + } } diff --git a/mobile/lib/model/grade_sheets/grade_sheet_record.dart b/mobile/lib/model/grade_sheets/grade_sheet_record.dart index c859eac3..d1e67440 100644 --- a/mobile/lib/model/grade_sheets/grade_sheet_record.dart +++ b/mobile/lib/model/grade_sheets/grade_sheet_record.dart @@ -17,6 +17,14 @@ class GradeSheetRecord { ); } + Map toJson() { + return { + 'id': id, + 'student_id': studentId, + 'grade': grade, + }; + } + factory GradeSheetRecord.empty() { return GradeSheetRecord(studentId: "", grade: "grade", id: ""); } diff --git a/mobile/lib/model/quiz/question.dart b/mobile/lib/model/quiz/question.dart index ce6de27e..5cbffc80 100644 --- a/mobile/lib/model/quiz/question.dart +++ b/mobile/lib/model/quiz/question.dart @@ -47,4 +47,11 @@ class Answer { factory Answer.fromJson(Map json) { return Answer(id: json['id'], text: json['text'] ?? ''); } + + Map toJson() { + return { + if (id != null) 'id': id, + 'text': text, + }; + } } diff --git a/mobile/lib/model/quiz/quiz.dart b/mobile/lib/model/quiz/quiz.dart index 94854a99..0750e423 100644 --- a/mobile/lib/model/quiz/quiz.dart +++ b/mobile/lib/model/quiz/quiz.dart @@ -49,4 +49,19 @@ class Quiz { .toList(), ); } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'total_points': totalPoints, + 'is_published': isPublished, + 'questions_count': questionsCount, + 'has_taken': hasTaken, + 'starts_at': startsAt?.toIso8601String(), + 'ends_at': endsAt?.toIso8601String(), + 'time_limit_in_minutes': timeLimitInMinutes, + 'questions': questions?.map((e) => e.toJson()).toList(), + }; + } } diff --git a/mobile/lib/model/staff/staff.dart b/mobile/lib/model/staff/staff.dart index 3458da7f..079b6868 100644 --- a/mobile/lib/model/staff/staff.dart +++ b/mobile/lib/model/staff/staff.dart @@ -27,6 +27,17 @@ class Staff { ); } + Map toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'permissions': permissions.map((p) => p.name).toList(), + 'created_at': createdAt, + }; + } + + factory Staff.empty() { return Staff(id: "", name: "", email: "", permissions: [], createdAt: ""); } diff --git a/mobile/lib/model/student/student.dart b/mobile/lib/model/student/student.dart index 5b93297d..aedd4cbd 100644 --- a/mobile/lib/model/student/student.dart +++ b/mobile/lib/model/student/student.dart @@ -20,6 +20,15 @@ class Student { ); } + Map toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'created_at': createdAt, + }; + } + factory Student.empty() { return Student(id: "", name: "", email: "", createdAt: ""); } diff --git a/mobile/lib/services/local_cache_service.dart b/mobile/lib/services/local_cache_service.dart new file mode 100644 index 00000000..abf20f39 --- /dev/null +++ b/mobile/lib/services/local_cache_service.dart @@ -0,0 +1,22 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:yaspo_mobile/abstract/local_cache_service.dart'; + +class LocalCacheService extends LocalCacheBaseService { + @override + Future cacheData(String boxName, dynamic data) async { + var box = await Hive.openBox(boxName); + await box.put('data', data); + } + + @override + Future getCachedData(String boxName) async { + var box = await Hive.openBox(boxName); + return box.get('data'); + } + + @override + Future clearCache(String boxName) async { + var box = await Hive.openBox(boxName); + await box.delete('data'); + } +} diff --git a/mobile/lib/services/remote_announcement_service.dart b/mobile/lib/services/remote_announcement_service.dart index 9501a4ca..b7f3055f 100644 --- a/mobile/lib/services/remote_announcement_service.dart +++ b/mobile/lib/services/remote_announcement_service.dart @@ -1,9 +1,32 @@ +import 'package:get_it/get_it.dart'; import 'package:yaspo_mobile/abstract/announcement_service.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/model/announcement/announcement.dart'; +import 'package:yaspo_mobile/abstract/local_cache_service.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; class RemoteAnnouncementService extends AnnouncementService { + final ConnectivityBaseService _connectivity = GetIt.I(); + final LocalCacheBaseService _cache = GetIt.I(); + final String _cacheKey = 'announcements'; + @override + Future> getAnnouncements() async { + try { + var response = await authenticatedDio.get("/announcements"); + if (response.statusCode == 200) { + List data = response.data; + await _cache.cacheData(_cacheKey, data); + return data.map((e) => Announcement.fromJson(e)).toList(); + } + } catch (_) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => Announcement.fromJson(Map.from(e))) + .toList(); + } Future> getAnnouncements([ String classId = 'single', ]) async { @@ -18,6 +41,11 @@ class RemoteAnnouncementService extends AnnouncementService { } @override + Future addAnnouncement(String name, String content) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + Future addAnnouncement( String name, String content, [ @@ -31,12 +59,20 @@ class RemoteAnnouncementService extends AnnouncementService { if (response.statusCode == 200 || response.statusCode == 201) { var data = response.data; - return Announcement( + var newAnnouncement = Announcement( id: data['id'], content: content, authorName: name, createdAt: DateTime.now().toIso8601String(), ); + + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(newAnnouncement.toJson()); + await _cache.cacheData(_cacheKey, list); + + return newAnnouncement; } else { throw Exception('Failed to add announcement'); } @@ -51,7 +87,15 @@ class RemoteAnnouncementService extends AnnouncementService { "/classes/$classId/announcements/$announcementId", ); - if (response.statusCode != 204) { + if (response.statusCode == 204) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == announcementId); + await _cache.cacheData(_cacheKey, list); + } + } else { throw Exception('Failed to delete announcement'); } } diff --git a/mobile/lib/services/remote_assignment_service.dart b/mobile/lib/services/remote_assignment_service.dart index 9a57f025..7d921eb9 100644 --- a/mobile/lib/services/remote_assignment_service.dart +++ b/mobile/lib/services/remote_assignment_service.dart @@ -1,17 +1,41 @@ import 'dart:io'; +import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; import 'package:yaspo_mobile/abstract/assignment_service.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/exceptions/assignment_exception.dart'; import 'package:yaspo_mobile/model/assignment/assignment.dart'; +import 'package:yaspo_mobile/abstract/local_cache_service.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; class RemoteAssignmentService extends AssignmentService { + final ConnectivityBaseService _connectivity = + GetIt.I(); + final LocalCacheBaseService _cache = GetIt.I(); + final String _cacheKey = 'assignments'; + @override Future> getAssignment([String classId = 'single']) async { var response = await authenticatedDio.get("/classes/$classId/assignments"); if (response.statusCode == 200) { List data = response.data; return data.map((e) => Assignment.fromJson(e)).toList(); + Future> getAssignment() async { + try { + var response = await authenticatedDio.get("/assignments"); + if (response.statusCode == 200) { + List data = response.data; + await _cache.cacheData(_cacheKey, data); + return data.map((e) => Assignment.fromJson(e)).toList(); + } + } catch (_) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => Assignment.fromJson(Map.from(e))) + .toList(); + } } return []; } @@ -22,6 +46,19 @@ class RemoteAssignmentService extends AssignmentService { String description, int maxGrade, String dueDate, + String author, + ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + + var response = await authenticatedDio.put( + "/assignments", + data: { + String title, + String description, + int maxGrade, + String dueDate, String author, [ String classId = 'single', ]) async { @@ -38,7 +75,7 @@ class RemoteAssignmentService extends AssignmentService { if (response.statusCode == 201) { var data = response.data; - return Assignment( + var newAssignment = Assignment( id: data['id'], title: title, description: description, @@ -46,6 +83,14 @@ class RemoteAssignmentService extends AssignmentService { dueDate: dueDate, author: author, ); + + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(newAssignment.toJson()); + await _cache.cacheData(_cacheKey, list); + + return newAssignment; } else { throw Exception('Failed to add assignment'); } @@ -56,9 +101,21 @@ class RemoteAssignmentService extends AssignmentService { String assignmentId, [ String classId = 'single', ]) async { + Future deleteAssignment(String assignmentId) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } var response = await authenticatedDio.delete("/classes/$classId/assignments/$assignmentId"); - if (response.statusCode != 204) { + if (response.statusCode == 204) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == assignmentId); + await _cache.cacheData(_cacheKey, list); + } + } else { throw Exception('Failed to delete assignment'); } } @@ -72,6 +129,16 @@ class RemoteAssignmentService extends AssignmentService { String dueDate, [ String classId = 'single', ]) async { + String assignmentId, + String title, + String description, + int maxGrade, + String dueDate, + ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + var data = { "title": title, "description": description, @@ -84,7 +151,24 @@ class RemoteAssignmentService extends AssignmentService { "/classes/$classId/assignments/$assignmentId", data: data, ); - if (response.statusCode == 200) return; + if (response.statusCode == 200) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == assignmentId); + if (index != -1) { + var item = Map.from(list[index]); + item['title'] = title; + item['description'] = description; + item['max_grade'] = maxGrade; + item['due_date'] = dueDate; + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } + return; + } } on DioException catch (e) { if (e.response?.statusCode == 404) { throw AssignmentNotFound; @@ -102,7 +186,20 @@ class RemoteAssignmentService extends AssignmentService { void Function(int, int) onProgress, [ String classId = 'single', ]) async { + Future submitAssignment( + String assignmentId, + String filePath, + int size, + String mimeType, + void Function(int, int) onProgress, + ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + var response = await authenticatedDio.put( + "/assignments/$assignmentId/submissions", + data: {"size": size.toString(), "type": mimeType}, "/classes/$classId/assignments/$assignmentId/submissions", data: {"size": size.toString(), "type": mimeType}, ); @@ -129,6 +226,22 @@ class RemoteAssignmentService extends AssignmentService { "/classes/$classId/assignments/$assignmentId/submissions/confirm", ); + if (confirmResponse.statusCode == 200) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == assignmentId); + if (index != -1) { + var item = Map.from(list[index]); + item['submitted'] = true; + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } + return; + } + throw Exception('Failed to confirm submission'); if (confirmResponse.statusCode != 200) { throw Exception('Failed to confirm submission'); } diff --git a/mobile/lib/services/remote_connectivity_service.dart b/mobile/lib/services/remote_connectivity_service.dart new file mode 100644 index 00000000..ac23f5eb --- /dev/null +++ b/mobile/lib/services/remote_connectivity_service.dart @@ -0,0 +1,14 @@ +import 'dart:io'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; + +class RemoteConnectivityService extends ConnectivityBaseService { + @override + Future hasInternet() async { + try { + var result = await InternetAddress.lookup('google.com'); + return result.isNotEmpty && result[0].rawAddress.isNotEmpty; + } on SocketException catch (_) { + return false; + } + } +} diff --git a/mobile/lib/services/remote_grade_sheet_service.dart b/mobile/lib/services/remote_grade_sheet_service.dart index d7dad23f..41afc54e 100644 --- a/mobile/lib/services/remote_grade_sheet_service.dart +++ b/mobile/lib/services/remote_grade_sheet_service.dart @@ -1,11 +1,19 @@ import 'package:dio/dio.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/exceptions/grade_sheet_record_exception.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/model/grade_sheets/grade_sheet.dart'; import 'package:yaspo_mobile/model/grade_sheets/grade_sheet_record.dart'; +import 'package:yaspo_mobile/abstract/local_cache_service.dart'; class RemoteGradeSheetService extends GradeSheetService { + final ConnectivityBaseService _connectivity = + GetIt.I(); + final LocalCacheBaseService _cache = GetIt.I(); + final String _cacheKey = 'grade_sheets'; + @override Future> getGradeSheets([String classId = 'single']) async { var response = await authenticatedDio.get("/classes/$classId/grade-sheets"); @@ -13,6 +21,21 @@ class RemoteGradeSheetService extends GradeSheetService { return (response.data as List) .map((e) => GradeSheet.fromJson(e)) .toList(); + Future> getGradeSheets() async { + try { + var response = await authenticatedDio.get("/grade-sheets"); + if (response.statusCode == 200) { + List data = response.data; + await _cache.cacheData(_cacheKey, data); + return data.map((e) => GradeSheet.fromJson(e)).toList(); + } + } catch (_) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => GradeSheet.fromJson(Map.from(e))) + .toList(); + } } return []; } @@ -23,15 +46,33 @@ class RemoteGradeSheetService extends GradeSheetService { bool visible, [ String classId = 'single', ]) async { + Future addGradeSheet(String name, bool visible) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + var data = {"name": name, "visible": visible}; var response = await authenticatedDio.put( "/classes/$classId/grade-sheets", data: data, ); + var response = await authenticatedDio.put("/grade-sheets", data: data); if (response.statusCode == 201) { - return GradeSheet(id: response.data["id"], name: name, visible: visible); + var newGradeSheet = GradeSheet( + id: response.data["id"], + name: name, + visible: visible, + ); + + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(newGradeSheet.toJson()); + await _cache.cacheData(_cacheKey, list); + + return newGradeSheet; } else { throw Exception('Failed to add grade sheet'); } @@ -42,11 +83,23 @@ class RemoteGradeSheetService extends GradeSheetService { String gradeSheetId, [ String classId = 'single', ]) async { + Future deleteGradeSheet(String gradeSheetId) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + try { var response = await authenticatedDio.delete( "/classes/$classId/grade-sheets/$gradeSheetId", ); if (response.statusCode == 204) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == gradeSheetId); + await _cache.cacheData(_cacheKey, list); + } return; } } on DioException catch (e) { @@ -64,12 +117,30 @@ class RemoteGradeSheetService extends GradeSheetService { bool visible, [ String classId = 'single', ]) async { + bool visible, + ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + try { var response = await authenticatedDio.post( - "/classes/$classId/grade-sheets/$gradeSheetId/visible", + "/grade-sheets/$gradeSheetId/visible", data: {"visible": visible}, ); if (response.statusCode == 200) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == gradeSheetId); + if (index != -1) { + var item = Map.from(list[index]); + item['visible'] = visible; + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } return; } } on DioException catch (e) { @@ -87,13 +158,22 @@ class RemoteGradeSheetService extends GradeSheetService { String gradeSheetId, [ String classId = 'single', ]) async { + Future> getRecords(String gradeSheetId) async { + String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; try { var response = await authenticatedDio.get( '/classes/$classId/grade-sheets/$gradeSheetId', ); if (response.statusCode == 200) { - return (response.data as List) - .map((e) => GradeSheetRecord.fromJson(e)) + List data = response.data; + await _cache.cacheData(recordsCacheKey, data); + return data.map((e) => GradeSheetRecord.fromJson(e)).toList(); + } + } catch (_) { + var cachedData = await _cache.getCachedData(recordsCacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => GradeSheetRecord.fromJson(Map.from(e))) .toList(); } } on DioException catch (e) { @@ -110,6 +190,12 @@ class RemoteGradeSheetService extends GradeSheetService { Future addStudentRecord( String studentId, String gradeSheetId, + String grade, + ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + String grade, [ String classId = 'single', ]) async { @@ -120,11 +206,20 @@ class RemoteGradeSheetService extends GradeSheetService { data: data, ); if (response.statusCode == 201) { - return GradeSheetRecord( + var newRecord = GradeSheetRecord( id: response.data["id"], studentId: studentId, grade: grade, ); + + // Update Cache + String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; + var cachedData = await _cache.getCachedData(recordsCacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(newRecord.toJson()); + await _cache.cacheData(recordsCacheKey, list); + + return newRecord; } } on DioException catch (e) { if (e.response?.statusCode == 404) { @@ -166,6 +261,14 @@ class RemoteGradeSheetService extends GradeSheetService { } @override + Future deleteStudentRecord( + String gradeSheetId, + String studentRecordId, + ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + Future deleteStudentRecord( String gradeSheetId, String studentRecordId, [ @@ -176,6 +279,14 @@ class RemoteGradeSheetService extends GradeSheetService { '/classes/$classId/grade-sheets/$gradeSheetId/records/$studentRecordId', ); if (response.statusCode == 204) { + // Update Cache + String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; + var cachedData = await _cache.getCachedData(recordsCacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == studentRecordId); + await _cache.cacheData(recordsCacheKey, list); + } return; } } on DioException catch (e) { @@ -191,6 +302,12 @@ class RemoteGradeSheetService extends GradeSheetService { Future updateStudentRecord( String studentRecordId, String gradeSheetId, + String grade, + ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + String grade, [ String classId = 'single', ]) async { @@ -200,6 +317,19 @@ class RemoteGradeSheetService extends GradeSheetService { data: {"grade": grade}, ); if (response.statusCode == 200) { + // Update Cache + String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; + var cachedData = await _cache.getCachedData(recordsCacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == studentRecordId); + if (index != -1) { + var item = Map.from(list[index]); + item['grade'] = grade; + list[index] = item; + await _cache.cacheData(recordsCacheKey, list); + } + } return; } } on DioException catch (e) { diff --git a/mobile/lib/services/remote_quiz_service.dart b/mobile/lib/services/remote_quiz_service.dart index 07ea923e..dfd102fc 100644 --- a/mobile/lib/services/remote_quiz_service.dart +++ b/mobile/lib/services/remote_quiz_service.dart @@ -1,23 +1,39 @@ +import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; import 'package:yaspo_mobile/abstract/quiz_service.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/exceptions/quiz_exception.dart'; import 'package:yaspo_mobile/model/quiz/question.dart'; import 'package:yaspo_mobile/model/quiz/quiz.dart'; import 'package:yaspo_mobile/model/quiz/submission.dart'; +import 'package:yaspo_mobile/abstract/local_cache_service.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; class RemoteQuizService implements QuizService { + final ConnectivityBaseService _connectivity = + GetIt.I(); + final LocalCacheBaseService _cache = GetIt.I(); + final String _cacheKey = 'quizzes'; + @override Future> getQuizzes([String classId = 'single']) async { try { var response = await authenticatedDio.get('/classes/$classId/quizzes'); if (response.statusCode == 200) { - return (response.data as List).map((e) => Quiz.fromJson(e)).toList(); + var data = response.data as List; + await _cache.cacheData(_cacheKey, data); + return data.map((e) => Quiz.fromJson(e)).toList(); + } + } catch (_) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => Quiz.fromJson(Map.from(e))) + .toList(); } - return []; - } catch (e) { - rethrow; } + return []; } @override @@ -27,11 +43,16 @@ class RemoteQuizService implements QuizService { '/classes/$classId/quizzes/$id', ); if (response.statusCode == 200) { + await _cache.cacheData('${_cacheKey}_$id', response.data); return Quiz.fromJson(response.data); } throw QuizNotFound(); - } on DioException catch (e) { - if (e.response?.statusCode == 404) { + } catch (e) { + var cachedData = await _cache.getCachedData('${_cacheKey}_$id'); + if (cachedData != null) { + return Quiz.fromJson(Map.from(cachedData)); + } + if (e is DioException && e.response?.statusCode == 404) { throw QuizNotFound(); } rethrow; @@ -43,19 +64,29 @@ class RemoteQuizService implements QuizService { String quizId, [ String classId = 'single', ]) async { + Future> getSubmissions(String quizId) async { + var cacheKey = '${_cacheKey}_${quizId}_submissions'; try { var response = await authenticatedDio.get( '/classes/$classId/quizzes/$quizId/submissions', ); if (response.statusCode == 200) { + var data = response.data as List; + await _cache.cacheData(cacheKey, data); + return data.map((e) => Submission.fromJson(e)).toList(); return (response.data as List) .map((e) => Submission.fromJson(e)) .toList(); } - return []; - } catch (e) { - rethrow; + } catch (_) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => Submission.fromJson(Map.from(e))) + .toList(); + } } + return []; } @override @@ -64,15 +95,28 @@ class RemoteQuizService implements QuizService { String submissionId, [ String classId = 'single', ]) async { + Future getSubmission( + String quizId, + String submissionId, + ) async { + var cacheKey = '${_cacheKey}_${quizId}_submission_$submissionId'; try { + var response = await authenticatedDio.get( + '/quizzes/$quizId/submissions/$submissionId', + ); var response = await authenticatedDio.get( '/classes/$classId/quizzes/$quizId/submissions/$submissionId', ); if (response.statusCode == 200) { + await _cache.cacheData(cacheKey, response.data); return SubmissionDetail.fromJson(response.data); } throw Exception('Failed to load submission'); } catch (e) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + return SubmissionDetail.fromJson(Map.from(cachedData)); + } rethrow; } } @@ -87,6 +131,12 @@ class RemoteQuizService implements QuizService { List questions, [ String classId = 'single', ]) async { + List questions, + ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + var data = { 'title': title, 'is_published': isPublished, @@ -102,7 +152,28 @@ class RemoteQuizService implements QuizService { data: data, ); if (response.statusCode == 201) { - return response.data['id']; + String id = response.data['id']; + int totalPoints = questions.fold(0, (sum, q) => sum + q.points); + + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + + var newQuiz = Quiz( + id: id, + title: title, + totalPoints: totalPoints, + isPublished: isPublished, + startsAt: startsAt, + endsAt: endsAt, + timeLimitInMinutes: timeLimitInMinutes, + questions: questions, + ); + + list.add(newQuiz.toJson()); + await _cache.cacheData(_cacheKey, list); + + return id; } throw Exception('Failed to create quiz'); } catch (e) { @@ -121,6 +192,12 @@ class RemoteQuizService implements QuizService { List questions, [ String classId = 'single', ]) async { + List questions, + ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + var data = { 'title': title, 'is_published': isPublished, @@ -138,6 +215,30 @@ class RemoteQuizService implements QuizService { if (response.statusCode != 200) { throw QuizUpdateFailed(); } + var response = await authenticatedDio.patch('/quizzes/$id', data: data); + if (response.statusCode == 200) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == id); + if (index != -1) { + int totalPoints = questions.fold(0, (sum, q) => sum + q.points); + var item = Map.from(list[index]); + item['title'] = title; + item['is_published'] = isPublished; + item['starts_at'] = startsAt.toIso8601String(); + item['ends_at'] = endsAt.toIso8601String(); + item['time_limit_in_minutes'] = timeLimitInMinutes; + item['total_points'] = totalPoints; + item['questions'] = questions.map((e) => e.toJson()).toList(); + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } + return; + } + throw QuizUpdateFailed(); } on DioException catch (e) { if (e.response?.statusCode == 404) { throw QuizNotFound(); @@ -150,6 +251,11 @@ class RemoteQuizService implements QuizService { } @override + Future deleteQuiz(String id) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + Future deleteQuiz(String id, [String classId = 'single']) async { try { var response = await authenticatedDio.delete( @@ -158,6 +264,18 @@ class RemoteQuizService implements QuizService { if (response.statusCode != 200) { throw Exception('Failed to delete quiz'); } + var response = await authenticatedDio.delete('/quizzes/$id'); + if (response.statusCode == 200) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == id); + await _cache.cacheData(_cacheKey, list); + } + return; + } + throw Exception('Failed to delete quiz'); } on DioException catch (e) { if (e.response?.statusCode == 404) { throw QuizNotFound(); @@ -168,11 +286,28 @@ class RemoteQuizService implements QuizService { @override Future startQuiz(String quizId, [String classId = 'single']) async { + Future startQuiz(String quizId) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + try { var response = await authenticatedDio.post( '/classes/$classId/quizzes/$quizId/start', ); if (response.statusCode == 200) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == quizId); + if (index != -1) { + var item = Map.from(list[index]); + item['has_taken'] = true; + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } return Quiz.fromJson(response.data); } throw Exception('Failed to start quiz'); @@ -190,6 +325,12 @@ class RemoteQuizService implements QuizService { @override Future submitQuiz( String quizId, + List> answers, + ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + List> answers, [ String classId = 'single', ]) async { @@ -199,6 +340,18 @@ class RemoteQuizService implements QuizService { data: {'answers': answers}, ); if (response.statusCode == 200) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == quizId); + if (index != -1) { + var item = Map.from(list[index]); + item['has_taken'] = true; + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } return QuizResult.fromJson(response.data); } throw Exception('Failed to submit quiz'); @@ -212,15 +365,22 @@ class RemoteQuizService implements QuizService { String quizId, [ String classId = 'single', ]) async { + Future getScore(String quizId) async { + var cacheKey = '${_cacheKey}_${quizId}_score'; try { var response = await authenticatedDio.get( '/classes/$classId/quizzes/$quizId/score', ); if (response.statusCode == 200) { + await _cache.cacheData(cacheKey, response.data); return QuizResult.fromJson(response.data); } throw Exception('Failed to get score'); } catch (e) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + return QuizResult.fromJson(Map.from(cachedData)); + } rethrow; } } diff --git a/mobile/lib/services/remote_staff_service.dart b/mobile/lib/services/remote_staff_service.dart index 2b5de101..63e4e574 100644 --- a/mobile/lib/services/remote_staff_service.dart +++ b/mobile/lib/services/remote_staff_service.dart @@ -1,19 +1,38 @@ +import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/abstract/staff_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/enums/permission.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/exceptions/staff_exception.dart'; import 'package:yaspo_mobile/exceptions/student_exception.dart'; import 'package:yaspo_mobile/model/class/class_model.dart'; import 'package:yaspo_mobile/model/staff/staff.dart'; +import 'package:yaspo_mobile/abstract/local_cache_service.dart'; class RemoteStaffService extends StaffService { + final ConnectivityBaseService _connectivity = + GetIt.I(); + final LocalCacheBaseService _cache = GetIt.I(); + final String _cacheKey = 'staff'; + @override Future> getStaff() async { - var response = await authenticatedDio.get("/staff"); - if (response.statusCode == 200) { - List data = response.data; - return data.map((e) => Staff.fromJson(e)).toList(); + try { + var response = await authenticatedDio.get("/staff"); + if (response.statusCode == 200) { + List data = response.data; + await _cache.cacheData(_cacheKey, data); + return data.map((e) => Staff.fromJson(e)).toList(); + } + } catch (_) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => Staff.fromJson(Map.from(e))) + .toList(); + } } return []; } @@ -41,16 +60,27 @@ class RemoteStaffService extends StaffService { data['password'] = password; data['send_instructions'] = sendInstructions; } + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } try { var response = await authenticatedDio.put('/staff', data: data); if (response.statusCode == 200) { - return Staff( + var newStaff = Staff( id: response.data['id'], name: name, email: email, permissions: permissions ?? [], createdAt: DateTime.now().toString(), ); + + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(newStaff.toJson()); + await _cache.cacheData(_cacheKey, list); + + return newStaff; } } on DioException catch (e) { if (e.response?.statusCode == 409) { @@ -64,8 +94,19 @@ class RemoteStaffService extends StaffService { @override Future deleteStaff(String id) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } var response = await authenticatedDio.delete('/staff/$id'); + if (response.statusCode == 200) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == id); + await _cache.cacheData(_cacheKey, list); + } } else { throw Exception('Failed to delete staff'); } @@ -78,6 +119,9 @@ class RemoteStaffService extends StaffService { String? email, List? permissions, }) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } Map data = {}; if (name != null) data['name'] = name; if (email != null) data['email'] = email; @@ -89,6 +133,22 @@ class RemoteStaffService extends StaffService { var response = await authenticatedDio.patch('/staff/$id', data: data); if (response.statusCode == 200) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == id); + if (index != -1) { + var item = Map.from(list[index]); + if (name != null) item['name'] = name; + if (email != null) item['email'] = email; + if (permissions != null) { + item['permissions'] = permissions.map((e) => e.name).toList(); + } + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } return; } } on DioException catch (e) { @@ -107,6 +167,9 @@ class RemoteStaffService extends StaffService { String? password, bool? sendInstructions, ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } Map data = {'autogenerate_password': autoGenerate}; if (!autoGenerate) { data['password'] = password; @@ -116,9 +179,7 @@ class RemoteStaffService extends StaffService { '/staff/$id/password', data: data, ); - if (response.statusCode == 200) { - return; - } else { + if (response.statusCode != 200) { throw Exception('Failed to reset password'); } } @@ -128,9 +189,15 @@ class RemoteStaffService extends StaffService { try { var response = await authenticatedDio.get("/staff/$id"); if (response.statusCode == 200) { + await _cache.cacheData('${_cacheKey}_$id', response.data); return Staff.fromJson(response.data); } } on DioException catch (e) { + var cachedData = await _cache.getCachedData('${_cacheKey}_$id'); + if (cachedData != null) { + return Staff.fromJson(cachedData); + } + if (e.response?.statusCode == 404) { throw UserNotFoundException(); } diff --git a/mobile/lib/services/remote_student_service.dart b/mobile/lib/services/remote_student_service.dart index e0d0d178..13e0ea8c 100644 --- a/mobile/lib/services/remote_student_service.dart +++ b/mobile/lib/services/remote_student_service.dart @@ -1,20 +1,37 @@ +import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/abstract/student_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/exceptions/staff_exception.dart'; import 'package:yaspo_mobile/exceptions/student_exception.dart'; import 'package:yaspo_mobile/model/class/class_model.dart'; import 'package:yaspo_mobile/model/student/student.dart'; +import 'package:yaspo_mobile/abstract/local_cache_service.dart'; class RemoteStudentService extends StudentService { + final ConnectivityBaseService _connectivity = + GetIt.I(); + final LocalCacheBaseService _cache = GetIt.I(); + final String _cacheKey = 'students'; + @override Future> getStudents() async { - var response = await authenticatedDio.get("/students"); - if (response.statusCode == 200) { - List students = (response.data as List) - .map((e) => Student.fromJson(e)) - .toList(); - return students; + try { + var response = await authenticatedDio.get("/students"); + if (response.statusCode == 200) { + List data = response.data; + await _cache.cacheData(_cacheKey, data); + return data.map((e) => Student.fromJson(e)).toList(); + } + } catch (_) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => Student.fromJson(Map.from(e))) + .toList(); + } } return []; } @@ -27,6 +44,10 @@ class RemoteStudentService extends StudentService { String? password, bool? sendInstructions, ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + Map data = { 'name': name, 'email': email, @@ -39,12 +60,20 @@ class RemoteStudentService extends StudentService { try { var response = await authenticatedDio.put('/students', data: data); if (response.statusCode == 200) { - return Student( + var newStudent = Student( id: response.data["student_id"], name: name, email: email, createdAt: DateTime.now().toIso8601String(), ); + + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(newStudent.toJson()); + await _cache.cacheData(_cacheKey, list); + + return newStudent; } } on DioException catch (e) { if (e.response?.statusCode == 409) { @@ -95,9 +124,20 @@ class RemoteStudentService extends StudentService { @override Future deleteStudent(String id) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + var response = await authenticatedDio.delete('/students/$id'); if (response.statusCode == 200) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == id); + await _cache.cacheData(_cacheKey, list); + } } else { throw Exception('Failed to delete student'); } @@ -105,10 +145,27 @@ class RemoteStudentService extends StudentService { @override Future updateStudentInfo(String id, String name, String email) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + Map data = {'name': name, 'email': email}; try { var response = await authenticatedDio.patch('/students/$id', data: data); if (response.statusCode == 200) { + // Update Cache + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == id); + if (index != -1) { + var item = Map.from(list[index]); + item['name'] = name; + item['email'] = email; + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } return; } } on DioException catch (e) { @@ -127,6 +184,10 @@ class RemoteStudentService extends StudentService { String? password, bool? sendInstructions, ) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } + Map data = {'autogenerate_password': autoGenerate}; if (!autoGenerate) { data['password'] = password; diff --git a/mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart b/mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart index 5a0ec738..aaaf4809 100644 --- a/mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:yaspo_mobile/abstract/analytics_service.dart'; import 'package:yaspo_mobile/abstract/announcement_service.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; @@ -71,12 +72,15 @@ class _AddAnnouncementScreenState extends State { parameters: {'status': 'failed'}, ); if (mounted) { + String msg = "Failed to add announcement"; + if (e is OfflineException) msg = e.message; + showTimedModalNotice( context: context, - duration: Duration(seconds: 3), + duration: const Duration(seconds: 3), icon: Icons.error_outline_outlined, iconColor: Theme.of(context).colorScheme.primary, - text: "Failed to add announcement", + text: msg, onComplete: (_) {}, ); } diff --git a/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart b/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart index 4fce087a..c18eafe5 100644 --- a/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:yaspo_mobile/abstract/analytics_service.dart'; import 'package:yaspo_mobile/abstract/assignment_service.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; @@ -163,19 +164,22 @@ class _CreateAssignmentScreenState extends State { Navigator.pop(context, newAssignment); }, ); - } catch (_) { + } catch (e) { if (mounted) { await GetIt.I().trackEvent( 'update_assignment', parameters: {'status': 'failed'}, ); if (!mounted) return; + String msg = 'Failed to create assignment'; + if (e is OfflineException) msg = e.message; + showTimedModalNotice( context: context, duration: const Duration(seconds: 3), icon: Icons.error_outline_outlined, iconColor: Theme.of(context).colorScheme.primary, - text: 'Failed to create assignment', + text: msg, onComplete: (_) {}, ); } diff --git a/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart b/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart index b194e063..ef3ee6b6 100644 --- a/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart @@ -4,6 +4,7 @@ import 'package:intl/intl.dart'; import 'package:yaspo_mobile/abstract/analytics_service.dart'; import 'package:yaspo_mobile/abstract/assignment_service.dart'; import 'package:yaspo_mobile/exceptions/assignment_exception.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/model/assignment/assignment.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; @@ -190,19 +191,22 @@ class _UpdateAssignmentScreenState extends State { onComplete: (_) {}, ); } - } catch (_) { + } catch (e) { if (mounted) { await GetIt.I().trackEvent( 'update_assignment', parameters: {'status': 'failed', 'error': 'unknown_error'}, ); if (!mounted) return; + String msg = 'Failed to update assignment'; + if (e is OfflineException) msg = e.message; + showTimedModalNotice( context: context, duration: const Duration(seconds: 3), icon: Icons.error_outline_outlined, iconColor: Theme.of(context).colorScheme.primary, - text: 'Failed to update assignment', + text: msg, onComplete: (_) {}, ); } diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart index a4eccc86..4ee77a5c 100644 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:yaspo_mobile/abstract/analytics_service.dart'; import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_cubit.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; @@ -69,12 +70,15 @@ class _AddGradeSheetScreenState extends State { parameters: {'status': 'failed'}, ); if (mounted) { + String msg = "An error has occurred"; + if (e is OfflineException) msg = e.message; + showTimedModalNotice( context: context, - duration: Duration(seconds: 3), + duration: const Duration(seconds: 3), icon: Icons.error_outline_outlined, iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", + text: msg, onComplete: (_) {}, ); } diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart index ec485eeb..be9915fc 100644 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart @@ -7,6 +7,7 @@ import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_record_cubit.dart'; import 'package:yaspo_mobile/exceptions/grade_sheet_record_exception.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; import 'package:yaspo_mobile/values/spaces.dart'; @@ -107,6 +108,20 @@ class _AddStudentGradeScreenState extends State { text: "An error has occurred", onComplete: (_) {}, ); + } catch (e) { + if (mounted) { + String msg = "An error has occurred"; + if (e is OfflineException) msg = e.message; + + showTimedModalNotice( + context: context, + duration: const Duration(seconds: 3), + icon: Icons.error_outline_outlined, + iconColor: Theme.of(context).colorScheme.primary, + text: msg, + onComplete: (_) {}, + ); + } } finally { if (mounted) setState(() => isSubmitting = false); } diff --git a/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart b/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart index b8f81669..adfcb6a0 100644 --- a/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; import 'package:yaspo_mobile/abstract/staff_service.dart'; import 'package:yaspo_mobile/abstract/student_service.dart'; import 'package:yaspo_mobile/model/staff/staff.dart'; import 'package:yaspo_mobile/model/student/student.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; @@ -100,6 +100,20 @@ class _ResetPasswordScreenUIState extends State { text: "An error has occurred", onComplete: (_) {}, ); + } catch (e) { + if (mounted) { + String msg = "An error has occurred"; + if (e is OfflineException) msg = e.message; + + showTimedModalNotice( + context: context, + duration: const Duration(seconds: 3), + icon: Icons.error_outline_outlined, + iconColor: Theme.of(context).colorScheme.primary, + text: msg, + onComplete: (_) {}, + ); + } } finally { if (mounted) setState(() => isSubmitting = false); } diff --git a/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart b/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart index 384cdff1..60e62036 100644 --- a/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart @@ -17,6 +17,8 @@ import 'package:yaspo_mobile/ui/app/single-class/staff/profile/widgets/staff_per import 'package:yaspo_mobile/ui/app/single-class/staff/profile/widgets/update_info_form.dart'; import 'package:yaspo_mobile/ui/app/single-class/staff/profile/widgets/update_info_header.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; +import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; import 'package:yaspo_mobile/values/spaces.dart'; @@ -237,6 +239,20 @@ class _UpdateInfoScreenState extends State { text: "An error has occurred", onComplete: (_) {}, ); + } catch (e) { + if (mounted) { + String msg = "An error has occurred"; + if (e is OfflineException) msg = e.message; + + showTimedModalNotice( + context: context, + duration: const Duration(seconds: 3), + icon: Icons.error_outline_outlined, + iconColor: Theme.of(context).colorScheme.primary, + text: msg, + onComplete: (_) {}, + ); + } } finally { if (mounted) setState(() => isSubmitting = false); } diff --git a/mobile/lib/ui/app/single-class/staff/quizzes/create_edit_quiz_screen.dart b/mobile/lib/ui/app/single-class/staff/quizzes/create_edit_quiz_screen.dart index 02ced146..26f6554b 100644 --- a/mobile/lib/ui/app/single-class/staff/quizzes/create_edit_quiz_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/quizzes/create_edit_quiz_screen.dart @@ -4,6 +4,7 @@ import 'package:intl/intl.dart'; import 'package:yaspo_mobile/abstract/analytics_service.dart'; import 'package:yaspo_mobile/abstract/quiz_service.dart'; import 'package:yaspo_mobile/exceptions/quiz_exception.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/model/quiz/question.dart'; import 'package:yaspo_mobile/model/quiz/quiz.dart'; import 'package:yaspo_mobile/ui/app/single-class/staff/quizzes/add_question_screen.dart'; @@ -238,6 +239,8 @@ class _CreateEditQuizScreenState extends State { if (mounted) { String msg = 'Failed to save quiz'; if (e is QuizUpdateFailed) msg = e.message; + if (e is OfflineException) msg = e.message; + showTimedModalNotice( context: context, duration: const Duration(seconds: 3), diff --git a/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart b/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart index a8deaf24..34372ee8 100644 --- a/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart @@ -3,6 +3,7 @@ import 'package:get_it/get_it.dart'; import 'package:yaspo_mobile/abstract/analytics_service.dart'; import 'package:yaspo_mobile/abstract/staff_service.dart'; import 'package:yaspo_mobile/enums/permission.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/exceptions/student_exception.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; @@ -122,12 +123,15 @@ class _AddStaffScreenState extends State { parameters: {'status': 'failed', 'error': 'unknown_error'}, ); if (mounted) { + String msg = "An error has occurred"; + if (e is OfflineException) msg = e.message; + showTimedModalNotice( context: context, duration: const Duration(seconds: 3), icon: Icons.error_outline_outlined, iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", + text: msg, onComplete: (_) {}, ); } diff --git a/mobile/lib/ui/app/single-class/staff/students/add_student_screen.dart b/mobile/lib/ui/app/single-class/staff/students/add_student_screen.dart index 486d3333..c1a5c574 100644 --- a/mobile/lib/ui/app/single-class/staff/students/add_student_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/students/add_student_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:yaspo_mobile/abstract/analytics_service.dart'; import 'package:yaspo_mobile/abstract/student_service.dart'; +import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; import 'package:yaspo_mobile/exceptions/student_exception.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; @@ -106,12 +107,15 @@ class _AddStudentScreenState extends State { parameters: {'status': 'failed', 'error': "unknown_error"}, ); if (mounted) { + String msg = "An error has occurred"; + if (e is OfflineException) msg = e.message; + showTimedModalNotice( context: context, duration: const Duration(seconds: 3), icon: Icons.error_outline_outlined, iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", + text: msg, onComplete: (_) {}, ); } diff --git a/mobile/lib/ui/app/single-class/student/quiz/quiz_session_screen.dart b/mobile/lib/ui/app/single-class/student/quiz/quiz_session_screen.dart index cd3245c2..5126ae9e 100644 --- a/mobile/lib/ui/app/single-class/student/quiz/quiz_session_screen.dart +++ b/mobile/lib/ui/app/single-class/student/quiz/quiz_session_screen.dart @@ -194,9 +194,7 @@ $e''', forceExit: true); context: context, builder: (context) => AlertDialog( title: const Text('Discard Quiz?'), - content: const Text( - 'Leaving now will not submit your answers. Are you sure?', - ), + content: const Text('Leaving now will not submit your answers. Are you sure?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 206f88c2..b0edde77 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -58,7 +58,5 @@ flutter: - assets/images/organization.png - assets/images/single-class.png - dependency_overrides: path_provider_android: 2.2.23 - From 18ef13d02bbac66ce8ba778e862b2e2c5c19b2e5 Mon Sep 17 00:00:00 2001 From: Ahmed Helmy Date: Wed, 11 Mar 2026 23:53:01 +0200 Subject: [PATCH 2/9] return the package i deleted wrong and fix the format and update packages. --- mobile/lib/cubit/quiz/quiz_session_cubit.dart | 8 +- mobile/lib/generated/assets.dart | 109 ++++++++++++++++++ .../assignments/create_assignment_screen.dart | 4 +- .../grade_sheets/add_grade_sheet_screen.dart | 4 +- .../staff/staff/add_staff_screen.dart | 15 +-- .../single-class/student/quiz/quiz_card.dart | 29 ++--- .../student/quiz/quiz_result_screen.dart | 18 +-- .../student/quiz/student_quiz_screen.dart | 21 +--- mobile/pubspec.yaml | 1 + 9 files changed, 132 insertions(+), 77 deletions(-) create mode 100644 mobile/lib/generated/assets.dart diff --git a/mobile/lib/cubit/quiz/quiz_session_cubit.dart b/mobile/lib/cubit/quiz/quiz_session_cubit.dart index ad347e90..6b4a4ffc 100644 --- a/mobile/lib/cubit/quiz/quiz_session_cubit.dart +++ b/mobile/lib/cubit/quiz/quiz_session_cubit.dart @@ -37,17 +37,13 @@ class QuizSessionCubit extends Cubit { void nextQuestion() { if (state.currentQuestionIndex < state.quiz.questions!.length - 1) { - emit( - state.copyWith(currentQuestionIndex: state.currentQuestionIndex + 1), - ); + emit(state.copyWith(currentQuestionIndex: state.currentQuestionIndex + 1)); } } void previousQuestion() { if (state.currentQuestionIndex > 0) { - emit( - state.copyWith(currentQuestionIndex: state.currentQuestionIndex - 1), - ); + emit(state.copyWith(currentQuestionIndex: state.currentQuestionIndex - 1)); } } diff --git a/mobile/lib/generated/assets.dart b/mobile/lib/generated/assets.dart new file mode 100644 index 00000000..e9047933 --- /dev/null +++ b/mobile/lib/generated/assets.dart @@ -0,0 +1,109 @@ +///This file is automatically generated. DO NOT EDIT, all your changes would be lost. +// ignore_for_file: dangling_library_doc_comments, implementation_imports +import 'package:flutter/widgets.dart'; + +class Assets { + Assets._(); + + static const AssetGenImage notificationBox = AssetGenImage( + 'assets/images/notification_box.png', + ); + static const AssetGenImage onboardingImage = AssetGenImage( + 'assets/images/onboarding_image.png', + ); + static const AssetGenImage organization = AssetGenImage( + 'assets/images/organization.png', + ); + static const AssetGenImage profilePicture = AssetGenImage( + 'assets/images/profile_picture.png', + ); + static const AssetGenImage singleClass = AssetGenImage( + 'assets/images/single-class.png', + ); + static const AssetGenImage twoAxis = AssetGenImage( + 'assets/images/two_axis.png', + ); + static const AssetGenImage yaspoLogo = AssetGenImage( + 'assets/images/yaspo_logo.png', + ); +} + +class AssetGenImage { + const AssetGenImage(this._assetName, {this.size, this.flavors = const {}}); + + final String _assetName; + + final Size? size; + final Set flavors; + + Image image({ + Key? key, + AssetBundle? bundle, + ImageFrameBuilder? frameBuilder, + ImageErrorWidgetBuilder? errorBuilder, + String? semanticLabel, + bool excludeFromSemantics = false, + double? scale, + double? width, + double? height, + Color? color, + Animation? opacity, + BlendMode? colorBlendMode, + BoxFit? fit, + AlignmentGeometry alignment = Alignment.center, + ImageRepeat repeat = ImageRepeat.noRepeat, + Rect? centerSlice, + bool matchTextDirection = false, + bool gaplessPlayback = false, + bool isAntiAlias = false, + String? package, + FilterQuality filterQuality = FilterQuality.low, + int? cacheWidth, + int? cacheHeight, + }) { + return Image.asset( + _assetName, + key: key, + bundle: bundle, + frameBuilder: frameBuilder, + errorBuilder: errorBuilder, + semanticLabel: semanticLabel, + excludeFromSemantics: excludeFromSemantics, + scale: scale, + width: width, + height: height, + color: color, + opacity: opacity, + colorBlendMode: colorBlendMode, + fit: fit, + alignment: alignment, + repeat: repeat, + centerSlice: centerSlice, + matchTextDirection: matchTextDirection, + gaplessPlayback: gaplessPlayback, + isAntiAlias: isAntiAlias, + package: package, + filterQuality: filterQuality, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + ); + } + + ImageProvider provider({AssetBundle? bundle, String? package}) { + return AssetImage(_assetName, bundle: bundle, package: package); + } + + Widget custom({ + Key? key, + required Widget Function(BuildContext context, String assetPath) builder, + }) { + return Builder( + key: key, + builder: (context) => builder(context, _assetName), + ); + } + + String get path => _assetName; + + String get keyName => _assetName; +} diff --git a/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart b/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart index c18eafe5..b7902601 100644 --- a/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart @@ -262,9 +262,7 @@ class _CreateAssignmentScreenState extends State { CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of( - context, - ).colorScheme.primary.withAlpha(204), + activeColor: Theme.of(context).colorScheme.primary.withAlpha(204), checkColor: Theme.of(context).colorScheme.surface, title: Text( "Visible", diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart index 4ee77a5c..6b955cf0 100644 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart @@ -145,9 +145,7 @@ class _AddGradeSheetScreenState extends State { child: CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.8), + activeColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.8), checkColor: Theme.of(context).colorScheme.surface, title: Text( "Visible", diff --git a/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart b/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart index 34372ee8..0c73bff1 100644 --- a/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart @@ -197,8 +197,7 @@ class _AddStaffScreenState extends State { labelText: "E-mail", textInputAction: TextInputAction.next, hintText: "ahmedElbelehy@twoaxis.org", - validator: (input) => - validateEmail(input, isSubmitted: emailSubmitted), + validator: (input) => validateEmail(input, isSubmitted: emailSubmitted), ), CheckboxListTile( contentPadding: EdgeInsets.zero, @@ -241,9 +240,7 @@ class _AddStaffScreenState extends State { "Send user instructions on how to login.", style: TextStyle( color: autoGeneratePassword - ? Theme.of( - context, - ).colorScheme.onSurfaceVariant + ? Theme.of(context).colorScheme.onSurfaceVariant : Theme.of(context).colorScheme.onSurface, fontSize: 16, ), @@ -279,14 +276,10 @@ class _AddStaffScreenState extends State { .toLowerCase() .split(' ') .map( - (e) => - e[0].toUpperCase() + e.substring(1), - ) + (e) => e[0].toUpperCase() + e.substring(1)) .join(' '), style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface, + color: Theme.of(context).colorScheme.onSurface, ), ), value: selectedPermissions.contains(permission), diff --git a/mobile/lib/ui/app/single-class/student/quiz/quiz_card.dart b/mobile/lib/ui/app/single-class/student/quiz/quiz_card.dart index a1995c62..3aa39f9e 100644 --- a/mobile/lib/ui/app/single-class/student/quiz/quiz_card.dart +++ b/mobile/lib/ui/app/single-class/student/quiz/quiz_card.dart @@ -35,12 +35,9 @@ class _QuizCardState extends State { super.dispose(); } - bool get _isStarted => - widget.quiz.startsAt == null || _now.isAfter(widget.quiz.startsAt!); - bool get _isEnded => - widget.quiz.endsAt != null && _now.isAfter(widget.quiz.endsAt!); - bool get _canTake => - _isStarted && !_isEnded && !(widget.quiz.hasTaken ?? false); + bool get _isStarted => widget.quiz.startsAt == null || _now.isAfter(widget.quiz.startsAt!); + bool get _isEnded => widget.quiz.endsAt != null && _now.isAfter(widget.quiz.endsAt!); + bool get _canTake => _isStarted && !_isEnded && !(widget.quiz.hasTaken ?? false); String _getStatusText() { if (widget.quiz.hasTaken ?? false) { @@ -104,9 +101,7 @@ class _QuizCardState extends State { ), color: _canTake || (widget.quiz.hasTaken ?? false) ? null - : Theme.of( - context, - ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + : Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), ), padding: const EdgeInsets.all(16), child: Column( @@ -128,19 +123,11 @@ class _QuizCardState extends State { if (widget.quiz.hasTaken ?? false) const Icon(Icons.check_circle, color: Colors.green) else if (_canTake) - Icon( - Icons.play_circle_outline, - color: Theme.of(context).colorScheme.primary, - ) + Icon(Icons.play_circle_outline, color: Theme.of(context).colorScheme.primary) else if (!_isStarted) const Icon(Icons.timer_outlined, color: Colors.orange) else - Icon( - Icons.lock_outline, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - ), + Icon(Icons.lock_outline, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5)), ], ), const SizedBox(height: 8), @@ -161,9 +148,7 @@ class _QuizCardState extends State { '${widget.quiz.questionsCount ?? 0} Questions', style: TextStyle( fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), ), ), ], diff --git a/mobile/lib/ui/app/single-class/student/quiz/quiz_result_screen.dart b/mobile/lib/ui/app/single-class/student/quiz/quiz_result_screen.dart index 12a298b2..a371e26b 100644 --- a/mobile/lib/ui/app/single-class/student/quiz/quiz_result_screen.dart +++ b/mobile/lib/ui/app/single-class/student/quiz/quiz_result_screen.dart @@ -67,10 +67,7 @@ ${snapshot.error}''', children: [ Text( widget.quizTitle, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 40), @@ -84,27 +81,20 @@ ${snapshot.error}''', value: result.score / result.totalPoints, strokeWidth: 12, color: _getScoreColor(percentage), - backgroundColor: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.2), + backgroundColor: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), ), ), Column( children: [ Text( '$percentage%', - style: const TextStyle( - fontSize: 48, - fontWeight: FontWeight.bold, - ), + style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold), ), Text( 'Score: ${result.score} / ${result.totalPoints}', style: TextStyle( fontSize: 16, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6) ), ), ], diff --git a/mobile/lib/ui/app/single-class/student/quiz/student_quiz_screen.dart b/mobile/lib/ui/app/single-class/student/quiz/student_quiz_screen.dart index 78e74f75..f38f8cf9 100644 --- a/mobile/lib/ui/app/single-class/student/quiz/student_quiz_screen.dart +++ b/mobile/lib/ui/app/single-class/student/quiz/student_quiz_screen.dart @@ -112,24 +112,9 @@ Please try again later.'''); } var now = DateTime.now(); - var upcomingQuizzes = quizzes - .where( - (q) => - !(q.hasTaken ?? false) && - (q.endsAt == null || q.endsAt!.isAfter(now)), - ) - .toList(); - var completedQuizzes = quizzes - .where((q) => q.hasTaken ?? false) - .toList(); - var missedQuizzes = quizzes - .where( - (q) => - !(q.hasTaken ?? false) && - q.endsAt != null && - q.endsAt!.isBefore(now), - ) - .toList(); + var upcomingQuizzes = quizzes.where((q) => !(q.hasTaken ?? false) && (q.endsAt == null || q.endsAt!.isAfter(now))).toList(); + var completedQuizzes = quizzes.where((q) => q.hasTaken ?? false).toList(); + var missedQuizzes = quizzes.where((q) => !(q.hasTaken ?? false) && q.endsAt != null && q.endsAt!.isBefore(now)).toList(); return ListView( padding: const EdgeInsets.symmetric(vertical: 16), diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b0edde77..47aab4f0 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: credential_manager: ^2.0.8 excel: ^4.0.6 path: ^1.9.1 + build_runner: ^2.12.2 dev_dependencies: From 2eacb5dfff65cd40e3ce41580264c4c6d4e909c1 Mon Sep 17 00:00:00 2001 From: Ahmed Helmy Date: Tue, 5 May 2026 20:09:19 +0300 Subject: [PATCH 3/9] rebase --- .../services/remote_announcement_service.dart | 25 ++------ .../services/remote_assignment_service.dart | 55 +++-------------- .../services/remote_grade_sheet_service.dart | 59 ++++--------------- mobile/lib/services/remote_quiz_service.dart | 41 +------------ 4 files changed, 28 insertions(+), 152 deletions(-) diff --git a/mobile/lib/services/remote_announcement_service.dart b/mobile/lib/services/remote_announcement_service.dart index b7f3055f..f5ffcb77 100644 --- a/mobile/lib/services/remote_announcement_service.dart +++ b/mobile/lib/services/remote_announcement_service.dart @@ -12,9 +12,10 @@ class RemoteAnnouncementService extends AnnouncementService { final String _cacheKey = 'announcements'; @override - Future> getAnnouncements() async { + Future> getAnnouncements([String classId = 'single']) async { try { - var response = await authenticatedDio.get("/announcements"); + var response = await authenticatedDio.get( + "/classes/$classId/announcements"); if (response.statusCode == 200) { List data = response.data; await _cache.cacheData(_cacheKey, data); @@ -27,30 +28,14 @@ class RemoteAnnouncementService extends AnnouncementService { .map((e) => Announcement.fromJson(Map.from(e))) .toList(); } - Future> getAnnouncements([ - String classId = 'single', - ]) async { - var response = await authenticatedDio.get( - "/classes/$classId/announcements", - ); - if (response.statusCode == 200) { - List data = response.data; - return data.map((e) => Announcement.fromJson(e)).toList(); } return []; } - @override - Future addAnnouncement(String name, String content) async { + Future addAnnouncement(String name, String content, [String classId = 'single']) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } - - Future addAnnouncement( - String name, - String content, [ - String classId = 'single', - ]) async { var response = await authenticatedDio.put( "/classes/$classId/announcements", data: {"content": content}, @@ -66,7 +51,6 @@ class RemoteAnnouncementService extends AnnouncementService { createdAt: DateTime.now().toIso8601String(), ); - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); List list = cachedData != null ? List.from(cachedData) : []; list.add(newAnnouncement.toJson()); @@ -88,7 +72,6 @@ class RemoteAnnouncementService extends AnnouncementService { ); if (response.statusCode == 204) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); diff --git a/mobile/lib/services/remote_assignment_service.dart b/mobile/lib/services/remote_assignment_service.dart index 7d921eb9..5c06bfa5 100644 --- a/mobile/lib/services/remote_assignment_service.dart +++ b/mobile/lib/services/remote_assignment_service.dart @@ -10,20 +10,14 @@ import 'package:yaspo_mobile/abstract/local_cache_service.dart'; import 'package:yaspo_mobile/exceptions/network_exception.dart'; class RemoteAssignmentService extends AssignmentService { - final ConnectivityBaseService _connectivity = - GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); final LocalCacheBaseService _cache = GetIt.I(); final String _cacheKey = 'assignments'; @override Future> getAssignment([String classId = 'single']) async { - var response = await authenticatedDio.get("/classes/$classId/assignments"); - if (response.statusCode == 200) { - List data = response.data; - return data.map((e) => Assignment.fromJson(e)).toList(); - Future> getAssignment() async { try { - var response = await authenticatedDio.get("/assignments"); + var response = await authenticatedDio.get("/classes/$classId/assignments"); if (response.statusCode == 200) { List data = response.data; await _cache.cacheData(_cacheKey, data); @@ -42,26 +36,15 @@ class RemoteAssignmentService extends AssignmentService { @override Future addAssignment( - String title, - String description, - int maxGrade, - String dueDate, - String author, - ) async { + String title, + String description, + int maxGrade, + String dueDate, + String author, [ + String classId = 'single']) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } - - var response = await authenticatedDio.put( - "/assignments", - data: { - String title, - String description, - int maxGrade, - String dueDate, - String author, [ - String classId = 'single', - ]) async { var response = await authenticatedDio.put( "/classes/$classId/assignments", data: { @@ -84,7 +67,6 @@ class RemoteAssignmentService extends AssignmentService { author: author, ); - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); List list = cachedData != null ? List.from(cachedData) : []; list.add(newAssignment.toJson()); @@ -101,14 +83,12 @@ class RemoteAssignmentService extends AssignmentService { String assignmentId, [ String classId = 'single', ]) async { - Future deleteAssignment(String assignmentId) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } var response = await authenticatedDio.delete("/classes/$classId/assignments/$assignmentId"); if (response.statusCode == 204) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -129,16 +109,9 @@ class RemoteAssignmentService extends AssignmentService { String dueDate, [ String classId = 'single', ]) async { - String assignmentId, - String title, - String description, - int maxGrade, - String dueDate, - ) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } - var data = { "title": title, "description": description, @@ -152,7 +125,6 @@ class RemoteAssignmentService extends AssignmentService { data: data, ); if (response.statusCode == 200) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -186,22 +158,13 @@ class RemoteAssignmentService extends AssignmentService { void Function(int, int) onProgress, [ String classId = 'single', ]) async { - Future submitAssignment( - String assignmentId, - String filePath, - int size, - String mimeType, - void Function(int, int) onProgress, - ) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } var response = await authenticatedDio.put( - "/assignments/$assignmentId/submissions", data: {"size": size.toString(), "type": mimeType}, "/classes/$classId/assignments/$assignmentId/submissions", - data: {"size": size.toString(), "type": mimeType}, ); if (response.statusCode != 200 && response.statusCode != 201) { @@ -227,7 +190,6 @@ class RemoteAssignmentService extends AssignmentService { ); if (confirmResponse.statusCode == 200) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -241,7 +203,6 @@ class RemoteAssignmentService extends AssignmentService { } return; } - throw Exception('Failed to confirm submission'); if (confirmResponse.statusCode != 200) { throw Exception('Failed to confirm submission'); } diff --git a/mobile/lib/services/remote_grade_sheet_service.dart b/mobile/lib/services/remote_grade_sheet_service.dart index 41afc54e..547e4dd0 100644 --- a/mobile/lib/services/remote_grade_sheet_service.dart +++ b/mobile/lib/services/remote_grade_sheet_service.dart @@ -16,14 +16,8 @@ class RemoteGradeSheetService extends GradeSheetService { @override Future> getGradeSheets([String classId = 'single']) async { - var response = await authenticatedDio.get("/classes/$classId/grade-sheets"); - if (response.statusCode == 200) { - return (response.data as List) - .map((e) => GradeSheet.fromJson(e)) - .toList(); - Future> getGradeSheets() async { try { - var response = await authenticatedDio.get("/grade-sheets"); + var response = await authenticatedDio.get("/classes/$classId/grade-sheets"); if (response.statusCode == 200) { List data = response.data; await _cache.cacheData(_cacheKey, data); @@ -46,7 +40,6 @@ class RemoteGradeSheetService extends GradeSheetService { bool visible, [ String classId = 'single', ]) async { - Future addGradeSheet(String name, bool visible) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } @@ -57,7 +50,6 @@ class RemoteGradeSheetService extends GradeSheetService { "/classes/$classId/grade-sheets", data: data, ); - var response = await authenticatedDio.put("/grade-sheets", data: data); if (response.statusCode == 201) { var newGradeSheet = GradeSheet( @@ -66,7 +58,6 @@ class RemoteGradeSheetService extends GradeSheetService { visible: visible, ); - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); List list = cachedData != null ? List.from(cachedData) : []; list.add(newGradeSheet.toJson()); @@ -83,7 +74,6 @@ class RemoteGradeSheetService extends GradeSheetService { String gradeSheetId, [ String classId = 'single', ]) async { - Future deleteGradeSheet(String gradeSheetId) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } @@ -93,7 +83,6 @@ class RemoteGradeSheetService extends GradeSheetService { "/classes/$classId/grade-sheets/$gradeSheetId", ); if (response.statusCode == 204) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -117,19 +106,16 @@ class RemoteGradeSheetService extends GradeSheetService { bool visible, [ String classId = 'single', ]) async { - bool visible, - ) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } try { var response = await authenticatedDio.post( - "/grade-sheets/$gradeSheetId/visible", + "/classes/$classId/grade-sheets/$gradeSheetId/visible", data: {"visible": visible}, ); if (response.statusCode == 200) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -158,7 +144,6 @@ class RemoteGradeSheetService extends GradeSheetService { String gradeSheetId, [ String classId = 'single', ]) async { - Future> getRecords(String gradeSheetId) async { String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; try { var response = await authenticatedDio.get( @@ -169,19 +154,18 @@ class RemoteGradeSheetService extends GradeSheetService { await _cache.cacheData(recordsCacheKey, data); return data.map((e) => GradeSheetRecord.fromJson(e)).toList(); } - } catch (_) { + } on DioException catch (e) { + if (e.response?.statusCode == 404) { + throw NotGradeSheetFound(); + } + } + catch (_) { var cachedData = await _cache.getCachedData(recordsCacheKey); if (cachedData != null) { return (cachedData as List) .map((e) => GradeSheetRecord.fromJson(Map.from(e))) .toList(); } - } on DioException catch (e) { - if (e.response?.statusCode == 404) { - throw NotGradeSheetFound(); - } else { - rethrow; - } } return []; } @@ -190,15 +174,11 @@ class RemoteGradeSheetService extends GradeSheetService { Future addStudentRecord( String studentId, String gradeSheetId, - String grade, + String grade, [String classId = 'single'] ) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } - - String grade, [ - String classId = 'single', - ]) async { var data = {"student_id": studentId, "grade": grade}; try { var response = await authenticatedDio.put( @@ -212,7 +192,6 @@ class RemoteGradeSheetService extends GradeSheetService { grade: grade, ); - // Update Cache String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; var cachedData = await _cache.getCachedData(recordsCacheKey); List list = cachedData != null ? List.from(cachedData) : []; @@ -261,25 +240,19 @@ class RemoteGradeSheetService extends GradeSheetService { } @override - Future deleteStudentRecord( - String gradeSheetId, - String studentRecordId, - ) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } - Future deleteStudentRecord( String gradeSheetId, String studentRecordId, [ String classId = 'single', ]) async { + if (!await _connectivity.hasInternet()) { + throw OfflineException('No internet connection'); + } try { var response = await authenticatedDio.delete( '/classes/$classId/grade-sheets/$gradeSheetId/records/$studentRecordId', ); if (response.statusCode == 204) { - // Update Cache String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; var cachedData = await _cache.getCachedData(recordsCacheKey); if (cachedData != null) { @@ -301,23 +274,17 @@ class RemoteGradeSheetService extends GradeSheetService { @override Future updateStudentRecord( String studentRecordId, - String gradeSheetId, - String grade, + String gradeSheetId, String grade, [String classId = 'single'] ) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } - - String grade, [ - String classId = 'single', - ]) async { try { var response = await authenticatedDio.patch( '/classes/$classId/grade-sheets/$gradeSheetId/records/$studentRecordId', data: {"grade": grade}, ); if (response.statusCode == 200) { - // Update Cache String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; var cachedData = await _cache.getCachedData(recordsCacheKey); if (cachedData != null) { diff --git a/mobile/lib/services/remote_quiz_service.dart b/mobile/lib/services/remote_quiz_service.dart index dfd102fc..f1d148c8 100644 --- a/mobile/lib/services/remote_quiz_service.dart +++ b/mobile/lib/services/remote_quiz_service.dart @@ -11,8 +11,7 @@ import 'package:yaspo_mobile/abstract/local_cache_service.dart'; import 'package:yaspo_mobile/exceptions/network_exception.dart'; class RemoteQuizService implements QuizService { - final ConnectivityBaseService _connectivity = - GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); final LocalCacheBaseService _cache = GetIt.I(); final String _cacheKey = 'quizzes'; @@ -64,7 +63,6 @@ class RemoteQuizService implements QuizService { String quizId, [ String classId = 'single', ]) async { - Future> getSubmissions(String quizId) async { var cacheKey = '${_cacheKey}_${quizId}_submissions'; try { var response = await authenticatedDio.get( @@ -74,9 +72,6 @@ class RemoteQuizService implements QuizService { var data = response.data as List; await _cache.cacheData(cacheKey, data); return data.map((e) => Submission.fromJson(e)).toList(); - return (response.data as List) - .map((e) => Submission.fromJson(e)) - .toList(); } } catch (_) { var cachedData = await _cache.getCachedData(cacheKey); @@ -95,15 +90,8 @@ class RemoteQuizService implements QuizService { String submissionId, [ String classId = 'single', ]) async { - Future getSubmission( - String quizId, - String submissionId, - ) async { var cacheKey = '${_cacheKey}_${quizId}_submission_$submissionId'; try { - var response = await authenticatedDio.get( - '/quizzes/$quizId/submissions/$submissionId', - ); var response = await authenticatedDio.get( '/classes/$classId/quizzes/$quizId/submissions/$submissionId', ); @@ -131,8 +119,6 @@ class RemoteQuizService implements QuizService { List questions, [ String classId = 'single', ]) async { - List questions, - ) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } @@ -155,7 +141,6 @@ class RemoteQuizService implements QuizService { String id = response.data['id']; int totalPoints = questions.fold(0, (sum, q) => sum + q.points); - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); List list = cachedData != null ? List.from(cachedData) : []; @@ -192,8 +177,6 @@ class RemoteQuizService implements QuizService { List questions, [ String classId = 'single', ]) async { - List questions, - ) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } @@ -215,9 +198,7 @@ class RemoteQuizService implements QuizService { if (response.statusCode != 200) { throw QuizUpdateFailed(); } - var response = await authenticatedDio.patch('/quizzes/$id', data: data); if (response.statusCode == 200) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -251,22 +232,15 @@ class RemoteQuizService implements QuizService { } @override - Future deleteQuiz(String id) async { + Future deleteQuiz(String id, [String classId = 'single']) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } - - Future deleteQuiz(String id, [String classId = 'single']) async { try { var response = await authenticatedDio.delete( '/classes/$classId/quizzes/$id', ); - if (response.statusCode != 200) { - throw Exception('Failed to delete quiz'); - } - var response = await authenticatedDio.delete('/quizzes/$id'); if (response.statusCode == 200) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -286,7 +260,6 @@ class RemoteQuizService implements QuizService { @override Future startQuiz(String quizId, [String classId = 'single']) async { - Future startQuiz(String quizId) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } @@ -296,7 +269,6 @@ class RemoteQuizService implements QuizService { '/classes/$classId/quizzes/$quizId/start', ); if (response.statusCode == 200) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -324,23 +296,17 @@ class RemoteQuizService implements QuizService { @override Future submitQuiz( - String quizId, - List> answers, + String quizId, List> answers, [String classId = 'single'] ) async { if (!await _connectivity.hasInternet()) { throw OfflineException('No internet connection'); } - - List> answers, [ - String classId = 'single', - ]) async { try { var response = await authenticatedDio.post( '/classes/$classId/quizzes/$quizId/submit', data: {'answers': answers}, ); if (response.statusCode == 200) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -365,7 +331,6 @@ class RemoteQuizService implements QuizService { String quizId, [ String classId = 'single', ]) async { - Future getScore(String quizId) async { var cacheKey = '${_cacheKey}_${quizId}_score'; try { var response = await authenticatedDio.get( From 6740e4f4863129f4991dc5e98100ebd2c3836b8d Mon Sep 17 00:00:00 2001 From: Ahmed Helmy Date: Fri, 27 Mar 2026 19:47:29 +0200 Subject: [PATCH 4/9] move announcement inside single class in test. --- .../staff}/announcement/add_announcement_page_test.dart | 4 ++-- .../staff}/announcement/announcement_page_test.dart | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) rename mobile/test/ui/app/{ => single-class/staff}/announcement/add_announcement_page_test.dart (97%) rename mobile/test/ui/app/{ => single-class/staff}/announcement/announcement_page_test.dart (99%) diff --git a/mobile/test/ui/app/announcement/add_announcement_page_test.dart b/mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart similarity index 97% rename from mobile/test/ui/app/announcement/add_announcement_page_test.dart rename to mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart index 6846079d..9ffc5da4 100644 --- a/mobile/test/ui/app/announcement/add_announcement_page_test.dart +++ b/mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart @@ -14,8 +14,8 @@ import 'package:yaspo_mobile/model/tenant/tenant.dart'; import 'package:yaspo_mobile/model/user/user.dart'; import 'package:yaspo_mobile/ui/app/single-class/staff/announcements/add_announcement_screen.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import '../../../mocks/analytics.mocks.dart'; -import '../../../mocks/announcement.mocks.dart'; + +import '../../../../../mocks/announcement.mocks.dart'; void main() { late MockAnnouncementService mockAnnouncementService; diff --git a/mobile/test/ui/app/announcement/announcement_page_test.dart b/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart similarity index 99% rename from mobile/test/ui/app/announcement/announcement_page_test.dart rename to mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart index 96ceabd6..d0f233bc 100644 --- a/mobile/test/ui/app/announcement/announcement_page_test.dart +++ b/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart @@ -17,6 +17,7 @@ import 'package:yaspo_mobile/ui/app/single-class/staff/announcements/announcemen import '../../../mocks/analytics.mocks.dart'; import '../../../mocks/announcement.mocks.dart'; +import '../../../../../mocks/announcement.mocks.dart'; void main() { late MockAnnouncementService mockAnnouncementService; From 99d2066fa964cf08659ab7d0e37be3eec1a909ba Mon Sep 17 00:00:00 2001 From: Ahmed Helmy Date: Wed, 8 Apr 2026 00:19:15 +0200 Subject: [PATCH 5/9] fix the conflicts. --- .../services/remote_announcement_service.dart | 3 +-- .../add_student_grade_screen.dart | 23 +++++-------------- .../staff/profile/reset_password_screen.dart | 13 ++--------- .../staff/profile/update_info_screen.dart | 15 +++--------- .../staff/staff/add_staff_screen.dart | 2 +- .../add_announcement_page_test.dart | 1 + .../announcement/announcement_page_test.dart | 3 +-- 7 files changed, 15 insertions(+), 45 deletions(-) diff --git a/mobile/lib/services/remote_announcement_service.dart b/mobile/lib/services/remote_announcement_service.dart index f5ffcb77..2bc104d6 100644 --- a/mobile/lib/services/remote_announcement_service.dart +++ b/mobile/lib/services/remote_announcement_service.dart @@ -14,8 +14,7 @@ class RemoteAnnouncementService extends AnnouncementService { @override Future> getAnnouncements([String classId = 'single']) async { try { - var response = await authenticatedDio.get( - "/classes/$classId/announcements"); + var response = await authenticatedDio.get("/classes/$classId/announcements"); if (response.statusCode == 200) { List data = response.data; await _cache.cacheData(_cacheKey, data); diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart index be9915fc..c3bd4b5f 100644 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart @@ -94,34 +94,23 @@ class _AddStudentGradeScreenState extends State { text: "Grade Sheet not found", onComplete: (_) {}, ); - } catch (_) { + } catch (e) { await GetIt.I().trackEvent( 'add_student_record', parameters: {'status': 'failed', 'error': 'unknown_error'}, ); if (!mounted) return; + String msg = "An error has occurred"; + if (e is OfflineException) msg = e.message; + showTimedModalNotice( context: context, - duration: Duration(seconds: 3), + duration: const Duration(seconds: 3), icon: Icons.error_outline_outlined, iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", + text: msg, onComplete: (_) {}, ); - } catch (e) { - if (mounted) { - String msg = "An error has occurred"; - if (e is OfflineException) msg = e.message; - - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: msg, - onComplete: (_) {}, - ); - } } finally { if (mounted) setState(() => isSubmitting = false); } diff --git a/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart b/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart index adfcb6a0..d54899c0 100644 --- a/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:yaspo_mobile/abstract/analytics_service.dart'; import 'package:yaspo_mobile/abstract/staff_service.dart'; import 'package:yaspo_mobile/abstract/student_service.dart'; import 'package:yaspo_mobile/model/staff/staff.dart'; @@ -79,7 +80,7 @@ class _ResetPasswordScreenUIState extends State { text: "Password reset successfully", onComplete: (_) => Navigator.pop(context), ); - } catch (_) { + } catch (e) { if (widget.student != null) { GetIt.I().trackEvent( 'reset_password', @@ -91,16 +92,6 @@ class _ResetPasswordScreenUIState extends State { parameters: {'type': 'staff', 'status': 'failed'}, ); } - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) {}, - ); - } catch (e) { if (mounted) { String msg = "An error has occurred"; if (e is OfflineException) msg = e.message; diff --git a/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart b/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart index 60e62036..f13b3331 100644 --- a/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart @@ -221,7 +221,7 @@ class _UpdateInfoScreenState extends State { text: "E-mail is already used", onComplete: (_) {}, ); - } catch (_) { + } catch (e) { await GetIt.I().trackEvent( 'update_info', parameters: { @@ -231,16 +231,7 @@ class _UpdateInfoScreenState extends State { }, ); if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) {}, - ); - } catch (e) { - if (mounted) { + String msg = "An error has occurred"; if (e is OfflineException) msg = e.message; @@ -252,7 +243,7 @@ class _UpdateInfoScreenState extends State { text: msg, onComplete: (_) {}, ); - } + } finally { if (mounted) setState(() => isSubmitting = false); } diff --git a/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart b/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart index 0c73bff1..363e7aaf 100644 --- a/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart @@ -117,7 +117,7 @@ class _AddStaffScreenState extends State { onComplete: (_) {}, ); } - } catch (_) { + } catch (e) { await GetIt.I().trackEvent( 'add_staff', parameters: {'status': 'failed', 'error': 'unknown_error'}, diff --git a/mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart b/mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart index 9ffc5da4..7e29b400 100644 --- a/mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart +++ b/mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart @@ -15,6 +15,7 @@ import 'package:yaspo_mobile/model/user/user.dart'; import 'package:yaspo_mobile/ui/app/single-class/staff/announcements/add_announcement_screen.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; +import '../../../../../mocks/analytics.mocks.dart'; import '../../../../../mocks/announcement.mocks.dart'; void main() { diff --git a/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart b/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart index d0f233bc..7e193051 100644 --- a/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart +++ b/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart @@ -15,8 +15,7 @@ import 'package:yaspo_mobile/model/user/user.dart'; import 'package:yaspo_mobile/ui/app/single-class/staff/announcements/announcements_screen.dart'; import 'package:yaspo_mobile/ui/app/single-class/staff/announcements/announcement_widget.dart'; -import '../../../mocks/analytics.mocks.dart'; -import '../../../mocks/announcement.mocks.dart'; +import '../../../../../mocks/analytics.mocks.dart'; import '../../../../../mocks/announcement.mocks.dart'; void main() { From c742066c531d28d52ceb15ab815ca963cdc1743b Mon Sep 17 00:00:00 2001 From: Ahmed Helmy Date: Wed, 15 Apr 2026 14:03:21 +0200 Subject: [PATCH 6/9] fix conflicts and update packages. --- mobile/lib/generated/assets.dart | 109 ------------------ mobile/lib/main.dart | 1 - .../services/remote_grade_sheet_service.dart | 3 +- mobile/pubspec.yaml | 5 +- 4 files changed, 3 insertions(+), 115 deletions(-) delete mode 100644 mobile/lib/generated/assets.dart diff --git a/mobile/lib/generated/assets.dart b/mobile/lib/generated/assets.dart deleted file mode 100644 index e9047933..00000000 --- a/mobile/lib/generated/assets.dart +++ /dev/null @@ -1,109 +0,0 @@ -///This file is automatically generated. DO NOT EDIT, all your changes would be lost. -// ignore_for_file: dangling_library_doc_comments, implementation_imports -import 'package:flutter/widgets.dart'; - -class Assets { - Assets._(); - - static const AssetGenImage notificationBox = AssetGenImage( - 'assets/images/notification_box.png', - ); - static const AssetGenImage onboardingImage = AssetGenImage( - 'assets/images/onboarding_image.png', - ); - static const AssetGenImage organization = AssetGenImage( - 'assets/images/organization.png', - ); - static const AssetGenImage profilePicture = AssetGenImage( - 'assets/images/profile_picture.png', - ); - static const AssetGenImage singleClass = AssetGenImage( - 'assets/images/single-class.png', - ); - static const AssetGenImage twoAxis = AssetGenImage( - 'assets/images/two_axis.png', - ); - static const AssetGenImage yaspoLogo = AssetGenImage( - 'assets/images/yaspo_logo.png', - ); -} - -class AssetGenImage { - const AssetGenImage(this._assetName, {this.size, this.flavors = const {}}); - - final String _assetName; - - final Size? size; - final Set flavors; - - Image image({ - Key? key, - AssetBundle? bundle, - ImageFrameBuilder? frameBuilder, - ImageErrorWidgetBuilder? errorBuilder, - String? semanticLabel, - bool excludeFromSemantics = false, - double? scale, - double? width, - double? height, - Color? color, - Animation? opacity, - BlendMode? colorBlendMode, - BoxFit? fit, - AlignmentGeometry alignment = Alignment.center, - ImageRepeat repeat = ImageRepeat.noRepeat, - Rect? centerSlice, - bool matchTextDirection = false, - bool gaplessPlayback = false, - bool isAntiAlias = false, - String? package, - FilterQuality filterQuality = FilterQuality.low, - int? cacheWidth, - int? cacheHeight, - }) { - return Image.asset( - _assetName, - key: key, - bundle: bundle, - frameBuilder: frameBuilder, - errorBuilder: errorBuilder, - semanticLabel: semanticLabel, - excludeFromSemantics: excludeFromSemantics, - scale: scale, - width: width, - height: height, - color: color, - opacity: opacity, - colorBlendMode: colorBlendMode, - fit: fit, - alignment: alignment, - repeat: repeat, - centerSlice: centerSlice, - matchTextDirection: matchTextDirection, - gaplessPlayback: gaplessPlayback, - isAntiAlias: isAntiAlias, - package: package, - filterQuality: filterQuality, - cacheWidth: cacheWidth, - cacheHeight: cacheHeight, - ); - } - - ImageProvider provider({AssetBundle? bundle, String? package}) { - return AssetImage(_assetName, bundle: bundle, package: package); - } - - Widget custom({ - Key? key, - required Widget Function(BuildContext context, String assetPath) builder, - }) { - return Builder( - key: key, - builder: (context) => builder(context, _assetName), - ); - } - - String get path => _assetName; - - String get keyName => _assetName; -} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 42609eb8..c5adb702 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -67,7 +67,6 @@ void main() async { var userCubit = UserCubit(); - GetIt.I.registerSingleton(HiveTokenStorageService()); GetIt.I.registerSingleton(HiveTokenStorageService()); GetIt.I.registerSingleton(RemoteConnectivityService()); GetIt.I.registerSingleton(LocalCacheService()); diff --git a/mobile/lib/services/remote_grade_sheet_service.dart b/mobile/lib/services/remote_grade_sheet_service.dart index 547e4dd0..493197f2 100644 --- a/mobile/lib/services/remote_grade_sheet_service.dart +++ b/mobile/lib/services/remote_grade_sheet_service.dart @@ -9,8 +9,7 @@ import 'package:yaspo_mobile/model/grade_sheets/grade_sheet_record.dart'; import 'package:yaspo_mobile/abstract/local_cache_service.dart'; class RemoteGradeSheetService extends GradeSheetService { - final ConnectivityBaseService _connectivity = - GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); final LocalCacheBaseService _cache = GetIt.I(); final String _cacheKey = 'grade_sheets'; diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 47aab4f0..25fff4d0 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: credential_manager: ^2.0.8 excel: ^4.0.6 path: ^1.9.1 - build_runner: ^2.12.2 + build_runner: ^2.15.0 dev_dependencies: @@ -42,7 +42,6 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 mockito: ^5.6.4 - build_runner: ^2.15.0 dart_jsonwebtoken: ^3.4.1 flutter_launcher_icons: ^0.14.4 @@ -60,4 +59,4 @@ flutter: - assets/images/single-class.png dependency_overrides: - path_provider_android: 2.2.23 + path_provider_android: ^2.3.1 From 23ea198343f62a183572ab29325fb1d1754492c7 Mon Sep 17 00:00:00 2001 From: Ahmed Helmy Date: Thu, 7 May 2026 19:26:10 +0300 Subject: [PATCH 7/9] make the request work when offline until connect the internet. --- mobile/lib/abstract/offline_sync_service.dart | 9 + mobile/lib/core/authenticated_dio.dart | 119 ++++++++++-- mobile/lib/cubit/user/user_cubit.dart | 29 +++ mobile/lib/main.dart | 6 +- .../lib/model/announcement/announcement.dart | 4 + mobile/lib/model/assignment/assignment.dart | 4 + mobile/lib/model/class/class_model.dart | 6 +- .../lib/model/grade_sheets/grade_sheet.dart | 11 +- .../offline_sync/offline_sync_request.dart | 39 ++++ mobile/lib/model/quiz/quiz.dart | 4 + mobile/lib/model/staff/staff.dart | 6 +- mobile/lib/model/student/student.dart | 6 +- .../services/remote_announcement_service.dart | 92 +++++++-- .../services/remote_assignment_service.dart | 150 ++++++++++++--- mobile/lib/services/remote_auth_service.dart | 3 +- mobile/lib/services/remote_class_service.dart | 136 ++++++++++++- .../services/remote_grade_sheet_service.dart | 144 ++++++++++---- .../services/remote_offline_sync_service.dart | 178 ++++++++++++++++++ mobile/lib/services/remote_quiz_service.dart | 166 ++++++++++++---- mobile/lib/services/remote_staff_service.dart | 123 ++++++++++-- .../lib/services/remote_student_service.dart | 123 +++++++++--- mobile/lib/services/remote_user_service.dart | 18 ++ .../staff/classes/staff_class_widget.dart | 89 +++++---- .../staff/staff/staff_widget.dart | 13 ++ .../staff/students/student_widget.dart | 13 ++ .../add_announcement_screen.dart | 2 +- .../announcements/announcement_widget.dart | 150 ++++++++------- .../announcements/announcements_screen.dart | 12 ++ .../staff/assignments/assignment_widget.dart | 166 ++++++++-------- .../assignments/create_assignment_screen.dart | 2 +- .../assignments/update_assignment_screen.dart | 2 +- .../class_settings/class_info_screen.dart | 2 +- .../class_settings/delete_class_screen.dart | 2 +- .../grade_sheets/grade_sheets_screen.dart | 2 +- .../staff/grade_sheets/sheet_item_widget.dart | 45 ++++- .../staff/profile/update_info_screen.dart | 1 - .../staff/quizzes/quiz_widget.dart | 52 +++-- .../staff/staff/staff_widget.dart | 13 ++ .../staff/students/student_widget.dart | 13 ++ mobile/pubspec.lock | 44 ++++- mobile/pubspec.yaml | 1 + 41 files changed, 1592 insertions(+), 408 deletions(-) create mode 100644 mobile/lib/abstract/offline_sync_service.dart create mode 100644 mobile/lib/model/offline_sync/offline_sync_request.dart create mode 100644 mobile/lib/services/remote_offline_sync_service.dart diff --git a/mobile/lib/abstract/offline_sync_service.dart b/mobile/lib/abstract/offline_sync_service.dart new file mode 100644 index 00000000..998ce983 --- /dev/null +++ b/mobile/lib/abstract/offline_sync_service.dart @@ -0,0 +1,9 @@ +import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; + +abstract class OfflineSyncService { + Stream get onSyncComplete; + + Future enqueueRequest(OfflineSyncRequest request); + + void onInternetConnected(); +} diff --git a/mobile/lib/core/authenticated_dio.dart b/mobile/lib/core/authenticated_dio.dart index 865cfa43..84b58a91 100644 --- a/mobile/lib/core/authenticated_dio.dart +++ b/mobile/lib/core/authenticated_dio.dart @@ -1,9 +1,11 @@ import 'dart:async'; - +import 'dart:io'; import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; import 'package:yaspo_mobile/core/api_url.dart'; import 'package:yaspo_mobile/abstract/token_storage_service.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; final authenticatedDio = Dio( BaseOptions(baseUrl: baseUrl, headers: {'Content-Type': 'application/json'}), @@ -12,6 +14,26 @@ final authenticatedDio = Dio( bool _isRefreshing = false; final List> _failedQueue = []; +class _OfflineRequest { + final Completer completer; + final RequestOptions requestOptions; + _OfflineRequest(this.completer, this.requestOptions); +} + +final List<_OfflineRequest> _offlineQueue = []; +bool _isConnectivityListening = false; + +bool _isOfflineError(DioException err) { + var method = err.requestOptions.method.toUpperCase(); + if (method == 'GET') return false; + + return err.type == DioExceptionType.connectionError || + err.type == DioExceptionType.connectionTimeout || + err.type == DioExceptionType.receiveTimeout || + err.type == DioExceptionType.sendTimeout || + err.error is SocketException; +} + Future _waitForTokenRefresh() { var completer = Completer(); _failedQueue.add(completer); @@ -29,7 +51,53 @@ void _processQueue(Object? error, String? token) { _failedQueue.clear(); } +Future _waitForInternetAndRetry(RequestOptions requestOptions) { + var completer = Completer(); + _offlineQueue.add(_OfflineRequest(completer, requestOptions)); + return completer.future; +} + +void _processOfflineQueue() async { + if (_offlineQueue.isEmpty) return; + + var queueCopy = List.of(_offlineQueue); + _offlineQueue.clear(); + + for (int i = 0; i < queueCopy.length; i++) { + var req = queueCopy[i]; + try { + var response = await authenticatedDio.fetch(req.requestOptions); + req.completer.complete(response); + } catch (e) { + if (e is DioException && _isOfflineError(e)) { + _offlineQueue.addAll(queueCopy.sublist(i)); + break; + } else { + req.completer.completeError(e); + } + } + } +} + +void _initConnectivityListener() { + if (_isConnectivityListening) return; + _isConnectivityListening = true; + Connectivity().onConnectivityChanged.listen((event) { + bool hasConnection = false; + hasConnection = event.any((result) => result != ConnectivityResult.none); + + if (hasConnection) { + GetIt.I().onInternetConnected(); + if (_offlineQueue.isNotEmpty) { + _processOfflineQueue(); + } + } + }); +} + void setupAuthenticatedDio() { + _initConnectivityListener(); + authenticatedDio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) async { @@ -42,6 +110,17 @@ void setupAuthenticatedDio() { onError: (err, handler) async { var originalRequest = err.requestOptions; + if (_isOfflineError(err) && + originalRequest.extra['offline_retry'] != true) { + originalRequest.extra['offline_retry'] = true; + try { + var response = await _waitForInternetAndRetry(originalRequest); + return handler.resolve(response); + } catch (e) { + return handler.reject(e as DioException); + } + } + if (err.response?.statusCode == 401 && originalRequest.extra['retry'] != true) { originalRequest.extra['retry'] = true; @@ -68,18 +147,34 @@ void setupAuthenticatedDio() { var newAccessToken = response.data['access_token']; var newRefreshToken = response.data['refresh_token']; - await GetIt.I().setTokens( - newAccessToken, - newRefreshToken, - ); - authenticatedDio.options.headers['Authorization'] = - 'Bearer $newAccessToken'; - _processQueue(null, newAccessToken); + if (newAccessToken != null && + newAccessToken.toString().isNotEmpty) { + String finalRefreshToken = + (newRefreshToken != null && + newRefreshToken.toString().isNotEmpty) + ? newRefreshToken + : refreshToken!; - originalRequest.headers['Authorization'] = - 'Bearer $newAccessToken'; - var retryResponse = await authenticatedDio.fetch(originalRequest); - return handler.resolve(retryResponse); + await GetIt.I().setTokens( + newAccessToken, + finalRefreshToken, + ); + authenticatedDio.options.headers['Authorization'] = + 'Bearer $newAccessToken'; + _processQueue(null, newAccessToken); + + originalRequest.headers['Authorization'] = + 'Bearer $newAccessToken'; + var retryResponse = await authenticatedDio.fetch( + originalRequest, + ); + return handler.resolve(retryResponse); + } else { + throw DioException( + requestOptions: originalRequest, + error: 'Refresh returned empty access token', + ); + } } catch (e) { _processQueue(e, null); return handler.reject(e as DioException); diff --git a/mobile/lib/cubit/user/user_cubit.dart b/mobile/lib/cubit/user/user_cubit.dart index 14e8a07b..436f06db 100644 --- a/mobile/lib/cubit/user/user_cubit.dart +++ b/mobile/lib/cubit/user/user_cubit.dart @@ -1,5 +1,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:yaspo_mobile/abstract/auth_service.dart'; +import 'package:yaspo_mobile/abstract/token_storage_service.dart'; import 'package:yaspo_mobile/model/user/user.dart'; +import 'package:yaspo_mobile/util/token_util.dart'; class UserCubit extends Cubit { UserCubit() : super(null); @@ -7,4 +11,29 @@ class UserCubit extends Cubit { void setUserModel(User? userModel) { emit(userModel); } + + Future refreshSession() async { + var tokenStorage = GetIt.I(); + var authService = GetIt.I(); + + var refreshToken = await tokenStorage.getRefreshToken(); + if (refreshToken == null || refreshToken.isEmpty) return; + + try { + var response = await authService.refresh(refreshToken); + + if (response.accessToken.isNotEmpty) { + String newRefreshToken = response.refreshToken.isNotEmpty + ? response.refreshToken + : refreshToken; + + await tokenStorage.setTokens(response.accessToken, newRefreshToken); + + var newUser = TokenUtil.decodeToken(response.accessToken); + emit(newUser); + } + } catch (e) { + rethrow; + } + } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c5adb702..ffa21f51 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -21,13 +21,13 @@ import 'package:yaspo_mobile/abstract/tenant_service.dart'; import 'package:yaspo_mobile/abstract/token_storage_service.dart'; import 'package:yaspo_mobile/abstract/user_service.dart'; import 'package:yaspo_mobile/abstract/class_service.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_cubit.dart'; import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_record_cubit.dart'; import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; import 'package:yaspo_mobile/enums/user_type.dart'; -import 'package:yaspo_mobile/exceptions/auth_exception.dart'; import 'package:yaspo_mobile/firebase_options.dart'; import 'package:yaspo_mobile/model/user/user.dart'; import 'package:yaspo_mobile/services/remote_connectivity_service.dart'; @@ -45,8 +45,9 @@ import 'package:yaspo_mobile/services/remote_staff_service.dart'; import 'package:yaspo_mobile/services/remote_student_service.dart'; import 'package:yaspo_mobile/services/remote_tenant_service.dart'; import 'package:yaspo_mobile/services/remote_class_service.dart'; -import 'package:yaspo_mobile/services/local_credential_manager_service.dart'; import 'package:yaspo_mobile/services/remote_user_service.dart'; +import 'package:yaspo_mobile/services/remote_offline_sync_service.dart'; +import 'package:yaspo_mobile/services/local_credential_manager_service.dart'; import 'package:yaspo_mobile/theme/color_scheme.dart'; import 'package:yaspo_mobile/ui/app/organization/student/student_organization_host.dart'; import 'package:yaspo_mobile/ui/app/single-class/staff/staff_app_host.dart'; @@ -86,6 +87,7 @@ void main() async { LocalCredentialManagerService(), ); GetIt.I.registerSingleton(RemoteClassService()); + GetIt.I.registerSingleton(RemoteOfflineSyncService()); await GetIt.I().init(); diff --git a/mobile/lib/model/announcement/announcement.dart b/mobile/lib/model/announcement/announcement.dart index 3f3e88b5..39999776 100644 --- a/mobile/lib/model/announcement/announcement.dart +++ b/mobile/lib/model/announcement/announcement.dart @@ -3,12 +3,14 @@ class Announcement { final String content; final String authorName; final String createdAt; + final bool isPending; Announcement({ required this.id, required this.content, required this.authorName, required this.createdAt, + this.isPending = false, }); factory Announcement.fromJson(Map json) { @@ -17,6 +19,7 @@ class Announcement { content: json['content'] ?? '', authorName: json['author'] ?? 'Unknown Author', createdAt: json['created_at'] ?? '', + isPending: json['isPending'] ?? false, ); } @@ -26,6 +29,7 @@ class Announcement { 'content': content, 'author': authorName, 'created_at': createdAt, + 'isPending': isPending, }; } } diff --git a/mobile/lib/model/assignment/assignment.dart b/mobile/lib/model/assignment/assignment.dart index c5e0ec2b..c9bb3500 100644 --- a/mobile/lib/model/assignment/assignment.dart +++ b/mobile/lib/model/assignment/assignment.dart @@ -6,6 +6,7 @@ class Assignment { final int maxGrade; final String dueDate; final bool submitted; + final bool isPending; Assignment({ required this.id, @@ -15,6 +16,7 @@ class Assignment { required this.maxGrade, required this.dueDate, this.submitted = false, + this.isPending = false, }); factory Assignment.fromJson(Map json) { @@ -26,6 +28,7 @@ class Assignment { maxGrade: json['max_grade'] ?? 0, dueDate: json['due_date'] ?? '', submitted: json['submitted'] ?? false, + isPending: json['isPending'] ?? false, ); } @@ -38,6 +41,7 @@ class Assignment { 'max_grade': maxGrade, 'due_date': dueDate, 'submitted': submitted, + 'isPending': isPending, }; } } diff --git a/mobile/lib/model/class/class_model.dart b/mobile/lib/model/class/class_model.dart index 3638ab9c..14ceef50 100644 --- a/mobile/lib/model/class/class_model.dart +++ b/mobile/lib/model/class/class_model.dart @@ -1,17 +1,19 @@ class ClassModel { final String id; final String name; + final bool isPending; - ClassModel({required this.id, required this.name}); + ClassModel({required this.id, required this.name, this.isPending = false}); factory ClassModel.fromJson(Map json) { return ClassModel( id: json['id']?.toString() ?? '', name: json['name']?.toString() ?? '', + isPending: json['isPending'] ?? false, ); } Map toJson() { - return {'id': id, 'name': name}; + return {'id': id, 'name': name, 'isPending': isPending}; } } diff --git a/mobile/lib/model/grade_sheets/grade_sheet.dart b/mobile/lib/model/grade_sheets/grade_sheet.dart index 93787bac..71213a00 100644 --- a/mobile/lib/model/grade_sheets/grade_sheet.dart +++ b/mobile/lib/model/grade_sheets/grade_sheet.dart @@ -3,8 +3,15 @@ class GradeSheet { final String name; final bool? visible; final String? grade; + final bool isPending; - GradeSheet({required this.id, required this.name, this.visible, this.grade}); + GradeSheet({ + required this.id, + required this.name, + this.visible, + this.grade, + this.isPending = false, + }); factory GradeSheet.fromJson(Map json) { return GradeSheet( @@ -12,6 +19,7 @@ class GradeSheet { name: json['name'], visible: json['visible'], grade: json['grade'], + isPending: json['isPending'] ?? false, ); } @@ -21,6 +29,7 @@ class GradeSheet { 'name': name, 'visible': visible, 'grade': grade, + 'isPending': isPending, }; } } diff --git a/mobile/lib/model/offline_sync/offline_sync_request.dart b/mobile/lib/model/offline_sync/offline_sync_request.dart new file mode 100644 index 00000000..f67ee91a --- /dev/null +++ b/mobile/lib/model/offline_sync/offline_sync_request.dart @@ -0,0 +1,39 @@ +class OfflineSyncRequest { + final String tempId; + final String method; + final String url; + final Map data; + final String entityType; + final String classId; + + OfflineSyncRequest({ + required this.tempId, + required this.method, + required this.url, + required this.data, + required this.entityType, + required this.classId, + }); + + factory OfflineSyncRequest.fromJson(Map json) { + return OfflineSyncRequest( + tempId: json['tempId'] ?? '', + method: json['method'] ?? '', + url: json['url'] ?? '', + data: json['data'] != null ? Map.from(json['data']) : {}, + entityType: json['entityType'] ?? '', + classId: json['classId'] ?? 'single', + ); + } + + Map toJson() { + return { + 'tempId': tempId, + 'method': method, + 'url': url, + 'data': data, + 'entityType': entityType, + 'classId': classId, + }; + } +} diff --git a/mobile/lib/model/quiz/quiz.dart b/mobile/lib/model/quiz/quiz.dart index 0750e423..fa020366 100644 --- a/mobile/lib/model/quiz/quiz.dart +++ b/mobile/lib/model/quiz/quiz.dart @@ -11,6 +11,7 @@ class Quiz { final DateTime? endsAt; final int? timeLimitInMinutes; final List? questions; + final bool isPending; Quiz({ required this.id, @@ -23,6 +24,7 @@ class Quiz { this.endsAt, this.timeLimitInMinutes, this.questions, + this.isPending = false, }); factory Quiz.fromJson(Map json) { @@ -47,6 +49,7 @@ class Quiz { questions: (json['questions'] as List?) ?.map((e) => Question.fromJson(e)) .toList(), + isPending: json['isPending'] ?? false, ); } @@ -62,6 +65,7 @@ class Quiz { 'ends_at': endsAt?.toIso8601String(), 'time_limit_in_minutes': timeLimitInMinutes, 'questions': questions?.map((e) => e.toJson()).toList(), + 'isPending': isPending, }; } } diff --git a/mobile/lib/model/staff/staff.dart b/mobile/lib/model/staff/staff.dart index 079b6868..db2e8ae7 100644 --- a/mobile/lib/model/staff/staff.dart +++ b/mobile/lib/model/staff/staff.dart @@ -6,6 +6,7 @@ class Staff { final String email; final List permissions; final String createdAt; + final bool isPending; Staff({ required this.id, @@ -13,6 +14,7 @@ class Staff { required this.email, required this.permissions, required this.createdAt, + this.isPending = false, }); factory Staff.fromJson(Map json) { @@ -23,7 +25,8 @@ class Staff { permissions: (json['permissions'] as List) .map((p) => Permission.values.firstWhere((e) => e.name == p)) .toList(), - createdAt: json['created_at'], + createdAt: json['created_at'] ?? '', + isPending: json['isPending'] ?? false, ); } @@ -34,6 +37,7 @@ class Staff { 'email': email, 'permissions': permissions.map((p) => p.name).toList(), 'created_at': createdAt, + 'isPending': isPending, }; } diff --git a/mobile/lib/model/student/student.dart b/mobile/lib/model/student/student.dart index aedd4cbd..54bff93d 100644 --- a/mobile/lib/model/student/student.dart +++ b/mobile/lib/model/student/student.dart @@ -3,12 +3,14 @@ class Student { final String name; final String email; final String createdAt; + final bool isPending; Student({ required this.id, required this.name, required this.email, required this.createdAt, + this.isPending = false, }); factory Student.fromJson(Map json) { @@ -16,7 +18,8 @@ class Student { id: json['id'], name: json['name'], email: json['email'], - createdAt: json['created_at'], + createdAt: json['created_at'] ?? '', + isPending: json['isPending'] ?? false, ); } @@ -26,6 +29,7 @@ class Student { 'name': name, 'email': email, 'created_at': createdAt, + 'isPending': isPending, }; } diff --git a/mobile/lib/services/remote_announcement_service.dart b/mobile/lib/services/remote_announcement_service.dart index 2bc104d6..939be9f2 100644 --- a/mobile/lib/services/remote_announcement_service.dart +++ b/mobile/lib/services/remote_announcement_service.dart @@ -1,27 +1,33 @@ import 'package:get_it/get_it.dart'; import 'package:yaspo_mobile/abstract/announcement_service.dart'; -import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/model/announcement/announcement.dart'; import 'package:yaspo_mobile/abstract/local_cache_service.dart'; -import 'package:yaspo_mobile/exceptions/network_exception.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; +import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; class RemoteAnnouncementService extends AnnouncementService { - final ConnectivityBaseService _connectivity = GetIt.I(); final LocalCacheBaseService _cache = GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); final String _cacheKey = 'announcements'; @override - Future> getAnnouncements([String classId = 'single']) async { + Future> getAnnouncements([ + String classId = 'single', + ]) async { + String cacheKey = '${_cacheKey}_$classId'; try { - var response = await authenticatedDio.get("/classes/$classId/announcements"); + var response = await authenticatedDio.get( + "/classes/$classId/announcements", + ); if (response.statusCode == 200) { List data = response.data; - await _cache.cacheData(_cacheKey, data); + await _cache.cacheData(cacheKey, data); return data.map((e) => Announcement.fromJson(e)).toList(); } } catch (_) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { return (cachedData as List) .map((e) => Announcement.fromJson(Map.from(e))) @@ -30,17 +36,50 @@ class RemoteAnnouncementService extends AnnouncementService { } return []; } + @override - Future addAnnouncement(String name, String content, [String classId = 'single']) async { + Future addAnnouncement( + String name, + String content, [ + String classId = 'single', + ]) async { + String cacheKey = '${_cacheKey}_$classId'; + if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); + String tempId = 'pending_${DateTime.now().millisecondsSinceEpoch}'; + var optimistic = Announcement( + id: tempId, + content: content, + authorName: name, + createdAt: DateTime.now().toIso8601String(), + isPending: true, + ); + + var cachedData = await _cache.getCachedData(cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(optimistic.toJson()); + await _cache.cacheData(cacheKey, list); + + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: tempId, + method: 'PUT', + url: "/classes/$classId/announcements", + data: {"content": content}, + entityType: 'announcement', + classId: classId, + ), + ); + + return optimistic; } + var response = await authenticatedDio.put( "/classes/$classId/announcements", data: {"content": content}, ); - if (response.statusCode == 200 || response.statusCode == 201) { + if (response.statusCode == 201) { var data = response.data; var newAnnouncement = Announcement( @@ -50,10 +89,10 @@ class RemoteAnnouncementService extends AnnouncementService { createdAt: DateTime.now().toIso8601String(), ); - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); List list = cachedData != null ? List.from(cachedData) : []; list.add(newAnnouncement.toJson()); - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); return newAnnouncement; } else { @@ -66,16 +105,41 @@ class RemoteAnnouncementService extends AnnouncementService { String announcementId, [ String classId = 'single', ]) async { + String cacheKey = '${_cacheKey}_$classId'; + + if (!await _connectivity.hasInternet()) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == announcementId); + await _cache.cacheData(cacheKey, list); + } + + if (!announcementId.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: announcementId, + method: 'DELETE', + url: "/classes/$classId/announcements/$announcementId", + data: {}, + entityType: 'announcement', + classId: classId, + ), + ); + } + return; + } + var response = await authenticatedDio.delete( "/classes/$classId/announcements/$announcementId", ); if (response.statusCode == 204) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { List list = List.from(cachedData); list.removeWhere((e) => e['id'] == announcementId); - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); } } else { throw Exception('Failed to delete announcement'); diff --git a/mobile/lib/services/remote_assignment_service.dart b/mobile/lib/services/remote_assignment_service.dart index 5c06bfa5..01793531 100644 --- a/mobile/lib/services/remote_assignment_service.dart +++ b/mobile/lib/services/remote_assignment_service.dart @@ -2,29 +2,33 @@ import 'dart:io'; import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; import 'package:yaspo_mobile/abstract/assignment_service.dart'; -import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/exceptions/assignment_exception.dart'; import 'package:yaspo_mobile/model/assignment/assignment.dart'; import 'package:yaspo_mobile/abstract/local_cache_service.dart'; -import 'package:yaspo_mobile/exceptions/network_exception.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; +import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; class RemoteAssignmentService extends AssignmentService { - final ConnectivityBaseService _connectivity = GetIt.I(); final LocalCacheBaseService _cache = GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); final String _cacheKey = 'assignments'; @override Future> getAssignment([String classId = 'single']) async { + String cacheKey = '${_cacheKey}_$classId'; try { - var response = await authenticatedDio.get("/classes/$classId/assignments"); + var response = await authenticatedDio.get( + "/classes/$classId/assignments", + ); if (response.statusCode == 200) { List data = response.data; - await _cache.cacheData(_cacheKey, data); + await _cache.cacheData(cacheKey, data); return data.map((e) => Assignment.fromJson(e)).toList(); } } catch (_) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { return (cachedData as List) .map((e) => Assignment.fromJson(Map.from(e))) @@ -36,15 +40,51 @@ class RemoteAssignmentService extends AssignmentService { @override Future addAssignment( - String title, - String description, - int maxGrade, - String dueDate, - String author, [ - String classId = 'single']) async { + String title, + String description, + int maxGrade, + String dueDate, + String author, [ + String classId = 'single', + ]) async { + String cacheKey = '${_cacheKey}_$classId'; + if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); + String tempId = 'pending_${DateTime.now().millisecondsSinceEpoch}'; + var optimistic = Assignment( + id: tempId, + title: title, + description: description, + author: author, + maxGrade: maxGrade, + dueDate: dueDate, + isPending: true, + ); + + var cachedData = await _cache.getCachedData(cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(optimistic.toJson()); + await _cache.cacheData(cacheKey, list); + + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: tempId, + method: 'PUT', + url: "/classes/$classId/assignments", + data: { + "title": title, + "description": description, + "max_grade": maxGrade, + "due_date": dueDate, + }, + entityType: 'assignment', + classId: classId, + ), + ); + + return optimistic; } + var response = await authenticatedDio.put( "/classes/$classId/assignments", data: { @@ -67,10 +107,10 @@ class RemoteAssignmentService extends AssignmentService { author: author, ); - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); List list = cachedData != null ? List.from(cachedData) : []; list.add(newAssignment.toJson()); - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); return newAssignment; } else { @@ -83,17 +123,41 @@ class RemoteAssignmentService extends AssignmentService { String assignmentId, [ String classId = 'single', ]) async { + String cacheKey = '${_cacheKey}_$classId'; + if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == assignmentId); + await _cache.cacheData(cacheKey, list); + } + + if (!assignmentId.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: assignmentId, + method: 'DELETE', + url: "/classes/$classId/assignments/$assignmentId", + data: {}, + entityType: 'assignment', + classId: classId, + ), + ); + } + return; } - var response = await authenticatedDio.delete("/classes/$classId/assignments/$assignmentId"); + + var response = await authenticatedDio.delete( + "/classes/$classId/assignments/$assignmentId", + ); if (response.statusCode == 204) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { List list = List.from(cachedData); list.removeWhere((e) => e['id'] == assignmentId); - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); } } else { throw Exception('Failed to delete assignment'); @@ -109,9 +173,7 @@ class RemoteAssignmentService extends AssignmentService { String dueDate, [ String classId = 'single', ]) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } + String cacheKey = '${_cacheKey}_$classId'; var data = { "title": title, "description": description, @@ -119,13 +181,45 @@ class RemoteAssignmentService extends AssignmentService { "due_date": dueDate, }; + if (!await _connectivity.hasInternet()) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == assignmentId); + if (index != -1) { + var item = Map.from(list[index]); + item['title'] = title; + item['description'] = description; + item['max_grade'] = maxGrade; + item['due_date'] = dueDate; + item['isPending'] = true; + list[index] = item; + await _cache.cacheData(cacheKey, list); + } + } + + if (!assignmentId.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: assignmentId, + method: 'POST', + url: "/classes/$classId/assignments/$assignmentId", + data: data, + entityType: 'assignment', + classId: classId, + ), + ); + } + return; + } + try { var response = await authenticatedDio.post( "/classes/$classId/assignments/$assignmentId", data: data, ); if (response.statusCode == 200) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { List list = List.from(cachedData); int index = list.indexWhere((e) => e['id'] == assignmentId); @@ -136,7 +230,7 @@ class RemoteAssignmentService extends AssignmentService { item['max_grade'] = maxGrade; item['due_date'] = dueDate; list[index] = item; - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); } } return; @@ -158,9 +252,7 @@ class RemoteAssignmentService extends AssignmentService { void Function(int, int) onProgress, [ String classId = 'single', ]) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } + String cacheKey = '${_cacheKey}_$classId'; var response = await authenticatedDio.put( data: {"size": size.toString(), "type": mimeType}, @@ -190,7 +282,7 @@ class RemoteAssignmentService extends AssignmentService { ); if (confirmResponse.statusCode == 200) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { List list = List.from(cachedData); int index = list.indexWhere((e) => e['id'] == assignmentId); @@ -198,7 +290,7 @@ class RemoteAssignmentService extends AssignmentService { var item = Map.from(list[index]); item['submitted'] = true; list[index] = item; - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); } } return; diff --git a/mobile/lib/services/remote_auth_service.dart b/mobile/lib/services/remote_auth_service.dart index 44e4501c..439e800b 100644 --- a/mobile/lib/services/remote_auth_service.dart +++ b/mobile/lib/services/remote_auth_service.dart @@ -86,14 +86,15 @@ class RemoteAuthService extends AuthService { if (response.statusCode == 200) { return AuthResponse.fromJson(response.data); } + throw Exception('Failed to refresh token: ${response.statusCode}'); } on DioException catch (e) { if (e.response?.statusCode == 401) { throw InvalidCredentialException(); } + rethrow; } catch (e) { rethrow; } - return AuthResponse.empty(); } @override diff --git a/mobile/lib/services/remote_class_service.dart b/mobile/lib/services/remote_class_service.dart index ad228643..c85cd82e 100644 --- a/mobile/lib/services/remote_class_service.dart +++ b/mobile/lib/services/remote_class_service.dart @@ -1,44 +1,164 @@ +import 'package:get_it/get_it.dart'; import 'package:yaspo_mobile/abstract/class_service.dart'; +import 'package:yaspo_mobile/abstract/local_cache_service.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/model/class/class_model.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; +import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; class RemoteClassService extends ClassService { + final LocalCacheBaseService _cache = GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); + final String _cacheKey = 'classes'; + @override Future> getClasses() async { - var response = await authenticatedDio.get('/classes'); - if (response.statusCode == 200) { - List data = response.data; - return data.map((e) => ClassModel.fromJson(e)).toList(); + try { + var response = await authenticatedDio.get('/classes'); + if (response.statusCode == 200) { + List data = response.data; + await _cache.cacheData(_cacheKey, data); + return data.map((e) => ClassModel.fromJson(e)).toList(); + } + } catch (_) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => ClassModel.fromJson(Map.from(e))) + .toList(); + } } - throw Exception('Failed to fetch classes'); + return []; } @override Future createClass(String name) async { + if (!await _connectivity.hasInternet()) { + String tempId = 'pending_${DateTime.now().millisecondsSinceEpoch}'; + var optimistic = ClassModel(id: tempId, name: name, isPending: true); + + var cachedData = await _cache.getCachedData(_cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(optimistic.toJson()); + await _cache.cacheData(_cacheKey, list); + + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: tempId, + method: 'POST', + url: '/classes', + data: {'name': name}, + entityType: 'class', + classId: 'all', + ), + ); + return; + } + var response = await authenticatedDio.post( '/classes', data: {'name': name}, ); - if (response.statusCode != 200) { + if (response.statusCode == 200) { + var data = response.data; + var newClass = ClassModel(id: data['id']?.toString() ?? '', name: name); + + var cachedData = await _cache.getCachedData(_cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(newClass.toJson()); + await _cache.cacheData(_cacheKey, list); + } else { throw Exception('Failed to create class'); } } @override Future deleteClass(String id) async { + if (!await _connectivity.hasInternet()) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id']?.toString() == id); + await _cache.cacheData(_cacheKey, list); + } + + if (!id.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: id, + method: 'DELETE', + url: '/classes/$id', + data: {}, + entityType: 'class', + classId: 'all', + ), + ); + } + return; + } + var response = await authenticatedDio.delete('/classes/$id'); - if (response.statusCode != 200) { + if (response.statusCode == 200) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id']?.toString() == id); + await _cache.cacheData(_cacheKey, list); + } + } else { throw Exception('Failed to delete class'); } } @override Future updateClass(String id, String name) async { + if (!await _connectivity.hasInternet()) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id']?.toString() == id); + if (index != -1) { + var item = Map.from(list[index]); + item['name'] = name; + item['isPending'] = true; + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } + + if (!id.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: id, + method: 'PUT', + url: '/classes/$id', + data: {'name': name}, + entityType: 'class', + classId: 'all', + ), + ); + } + return; + } + var response = await authenticatedDio.put( '/classes/$id', data: {'name': name}, ); - if (response.statusCode != 200) { + if (response.statusCode == 200) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id']?.toString() == id); + if (index != -1) { + var item = Map.from(list[index]); + item['name'] = name; + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } + } else { throw Exception('Failed to update class'); } } diff --git a/mobile/lib/services/remote_grade_sheet_service.dart b/mobile/lib/services/remote_grade_sheet_service.dart index 493197f2..327f078b 100644 --- a/mobile/lib/services/remote_grade_sheet_service.dart +++ b/mobile/lib/services/remote_grade_sheet_service.dart @@ -1,29 +1,33 @@ import 'package:dio/dio.dart'; -import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/exceptions/grade_sheet_record_exception.dart'; -import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/model/grade_sheets/grade_sheet.dart'; import 'package:yaspo_mobile/model/grade_sheets/grade_sheet_record.dart'; import 'package:yaspo_mobile/abstract/local_cache_service.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; +import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; class RemoteGradeSheetService extends GradeSheetService { - final ConnectivityBaseService _connectivity = GetIt.I(); final LocalCacheBaseService _cache = GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); final String _cacheKey = 'grade_sheets'; @override Future> getGradeSheets([String classId = 'single']) async { + String cacheKey = '${_cacheKey}_$classId'; try { - var response = await authenticatedDio.get("/classes/$classId/grade-sheets"); + var response = await authenticatedDio.get( + "/classes/$classId/grade-sheets", + ); if (response.statusCode == 200) { List data = response.data; - await _cache.cacheData(_cacheKey, data); + await _cache.cacheData(cacheKey, data); return data.map((e) => GradeSheet.fromJson(e)).toList(); } } catch (_) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { return (cachedData as List) .map((e) => GradeSheet.fromJson(Map.from(e))) @@ -39,11 +43,36 @@ class RemoteGradeSheetService extends GradeSheetService { bool visible, [ String classId = 'single', ]) async { + String cacheKey = '${_cacheKey}_$classId'; + var data = {"name": name, "visible": visible}; + if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } + String tempId = 'pending_${DateTime.now().millisecondsSinceEpoch}'; + var optimistic = GradeSheet( + id: tempId, + name: name, + visible: visible, + isPending: true, + ); - var data = {"name": name, "visible": visible}; + var cachedData = await _cache.getCachedData(cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(optimistic.toJson()); + await _cache.cacheData(cacheKey, list); + + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: tempId, + method: 'PUT', + url: "/classes/$classId/grade-sheets", + data: data, + entityType: 'grade_sheet', + classId: classId, + ), + ); + + return optimistic; + } var response = await authenticatedDio.put( "/classes/$classId/grade-sheets", @@ -57,10 +86,10 @@ class RemoteGradeSheetService extends GradeSheetService { visible: visible, ); - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); List list = cachedData != null ? List.from(cachedData) : []; list.add(newGradeSheet.toJson()); - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); return newGradeSheet; } else { @@ -73,8 +102,29 @@ class RemoteGradeSheetService extends GradeSheetService { String gradeSheetId, [ String classId = 'single', ]) async { + String cacheKey = '${_cacheKey}_$classId'; + if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == gradeSheetId); + await _cache.cacheData(cacheKey, list); + } + + if (!gradeSheetId.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: gradeSheetId, + method: 'DELETE', + url: "/classes/$classId/grade-sheets/$gradeSheetId", + data: {}, + entityType: 'grade_sheet', + classId: classId, + ), + ); + } + return; } try { @@ -82,11 +132,11 @@ class RemoteGradeSheetService extends GradeSheetService { "/classes/$classId/grade-sheets/$gradeSheetId", ); if (response.statusCode == 204) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { List list = List.from(cachedData); list.removeWhere((e) => e['id'] == gradeSheetId); - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); } return; } @@ -105,8 +155,35 @@ class RemoteGradeSheetService extends GradeSheetService { bool visible, [ String classId = 'single', ]) async { + String cacheKey = '${_cacheKey}_$classId'; + if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == gradeSheetId); + if (index != -1) { + var item = Map.from(list[index]); + item['visible'] = visible; + item['isPending'] = true; + list[index] = item; + await _cache.cacheData(cacheKey, list); + } + } + + if (!gradeSheetId.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: gradeSheetId, + method: 'POST', + url: "/classes/$classId/grade-sheets/$gradeSheetId/visible", + data: {"visible": visible}, + entityType: 'grade_sheet', + classId: classId, + ), + ); + } + return; } try { @@ -115,7 +192,7 @@ class RemoteGradeSheetService extends GradeSheetService { data: {"visible": visible}, ); if (response.statusCode == 200) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { List list = List.from(cachedData); int index = list.indexWhere((e) => e['id'] == gradeSheetId); @@ -123,7 +200,7 @@ class RemoteGradeSheetService extends GradeSheetService { var item = Map.from(list[index]); item['visible'] = visible; list[index] = item; - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); } } return; @@ -137,13 +214,12 @@ class RemoteGradeSheetService extends GradeSheetService { } } - // grade Sheet Records @override Future> getRecords( String gradeSheetId, [ String classId = 'single', ]) async { - String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; + String recordsCacheKey = '${_cacheKey}_${classId}_${gradeSheetId}_records'; try { var response = await authenticatedDio.get( '/classes/$classId/grade-sheets/$gradeSheetId', @@ -157,8 +233,7 @@ class RemoteGradeSheetService extends GradeSheetService { if (e.response?.statusCode == 404) { throw NotGradeSheetFound(); } - } - catch (_) { + } catch (_) { var cachedData = await _cache.getCachedData(recordsCacheKey); if (cachedData != null) { return (cachedData as List) @@ -173,11 +248,9 @@ class RemoteGradeSheetService extends GradeSheetService { Future addStudentRecord( String studentId, String gradeSheetId, - String grade, [String classId = 'single'] - ) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } + String grade, [ + String classId = 'single', + ]) async { var data = {"student_id": studentId, "grade": grade}; try { var response = await authenticatedDio.put( @@ -191,7 +264,8 @@ class RemoteGradeSheetService extends GradeSheetService { grade: grade, ); - String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; + String recordsCacheKey = + '${_cacheKey}_${classId}_${gradeSheetId}_records'; var cachedData = await _cache.getCachedData(recordsCacheKey); List list = cachedData != null ? List.from(cachedData) : []; list.add(newRecord.toJson()); @@ -244,15 +318,12 @@ class RemoteGradeSheetService extends GradeSheetService { String studentRecordId, [ String classId = 'single', ]) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } + String recordsCacheKey = '${_cacheKey}_${classId}_${gradeSheetId}_records'; try { var response = await authenticatedDio.delete( '/classes/$classId/grade-sheets/$gradeSheetId/records/$studentRecordId', ); if (response.statusCode == 204) { - String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; var cachedData = await _cache.getCachedData(recordsCacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -273,18 +344,17 @@ class RemoteGradeSheetService extends GradeSheetService { @override Future updateStudentRecord( String studentRecordId, - String gradeSheetId, String grade, [String classId = 'single'] - ) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } + String gradeSheetId, + String grade, [ + String classId = 'single', + ]) async { + String recordsCacheKey = '${_cacheKey}_${classId}_${gradeSheetId}_records'; try { var response = await authenticatedDio.patch( '/classes/$classId/grade-sheets/$gradeSheetId/records/$studentRecordId', data: {"grade": grade}, ); if (response.statusCode == 200) { - String recordsCacheKey = '${_cacheKey}_${gradeSheetId}_records'; var cachedData = await _cache.getCachedData(recordsCacheKey); if (cachedData != null) { List list = List.from(cachedData); diff --git a/mobile/lib/services/remote_offline_sync_service.dart b/mobile/lib/services/remote_offline_sync_service.dart new file mode 100644 index 00000000..c83ca99d --- /dev/null +++ b/mobile/lib/services/remote_offline_sync_service.dart @@ -0,0 +1,178 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:yaspo_mobile/abstract/local_cache_service.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; +import 'package:yaspo_mobile/core/authenticated_dio.dart'; +import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; + +class RemoteOfflineSyncService implements OfflineSyncService { + final String boxName = 'offline_sync_requests'; + bool _isSyncing = false; + final _syncEventController = StreamController.broadcast(); + + @override + Stream get onSyncComplete => _syncEventController.stream; + + @override + Future enqueueRequest(OfflineSyncRequest request) async { + var box = await Hive.openBox(boxName); + await box.add(request.toJson()); + _attemptSync(); + } + + Future _attemptSync() async { + if (_isSyncing) return; + _isSyncing = true; + try { + var box = await Hive.openBox(boxName); + if (box.isEmpty) return; + + List keysToRemove = []; + for (var key in box.keys) { + var reqJson = box.get(key); + if (reqJson == null) continue; + var req = OfflineSyncRequest.fromJson( + Map.from(reqJson), + ); + + try { + Response response; + var options = Options(extra: {'offline_retry': true}); + + if (req.method == 'PUT') { + response = await authenticatedDio.put( + req.url, + data: req.data, + options: options, + ); + } else if (req.method == 'POST') { + response = await authenticatedDio.post( + req.url, + data: req.data, + options: options, + ); + } else if (req.method == 'PATCH') { + response = await authenticatedDio.patch( + req.url, + data: req.data, + options: options, + ); + } else if (req.method == 'DELETE') { + response = await authenticatedDio.delete(req.url, options: options); + } else { + keysToRemove.add(key); + continue; + } + + if (response.statusCode == 201 || + response.statusCode == 200 || + response.statusCode == 204) { + keysToRemove.add(key); + await _reconcileCache(req, response.data); + } + } on DioException catch (e) { + if (e.type == DioExceptionType.connectionError || + e.type == DioExceptionType.connectionTimeout || + e.error is SocketException) { + break; + } else { + keysToRemove.add(key); + await _revertCache(req); + } + } catch (e) { + keysToRemove.add(key); + await _revertCache(req); + } + } + + for (var key in keysToRemove) { + await box.delete(key); + } + } finally { + _isSyncing = false; + } + } + + Future _reconcileCache( + OfflineSyncRequest req, + dynamic responseData, + ) async { + LocalCacheBaseService cache = GetIt.I(); + String cacheKey = ''; + + if (req.entityType == 'announcement') { + cacheKey = 'announcements_${req.classId}'; + } else if (req.entityType == 'assignment') { + cacheKey = 'assignments_${req.classId}'; + } else if (req.entityType == 'quiz') { + cacheKey = 'quizzes_${req.classId}'; + } else if (req.entityType == 'class') { + cacheKey = 'classes'; + } else if (req.entityType == 'grade_sheet') { + cacheKey = 'grade_sheets_${req.classId}'; + } else if (req.entityType == 'staff') { + cacheKey = 'staff'; + } else if (req.entityType == 'student') { + cacheKey = 'students'; + } + + if (cacheKey.isNotEmpty) { + var cachedData = await cache.getCachedData(cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + if (req.method != 'DELETE') { + var index = list.indexWhere((e) => e['id']?.toString() == req.tempId); + if (index != -1) { + if (responseData != null && + responseData is Map && + responseData['id'] != null) { + list[index]['id'] = responseData['id']; + } + list[index]['isPending'] = false; + await cache.cacheData(cacheKey, list); + } + } + _syncEventController.add(null); + } + } + } + + Future _revertCache(OfflineSyncRequest req) async { + LocalCacheBaseService cache = GetIt.I(); + String cacheKey = ''; + + if (req.entityType == 'announcement') { + cacheKey = 'announcements_${req.classId}'; + } else if (req.entityType == 'assignment') { + cacheKey = 'assignments_${req.classId}'; + } else if (req.entityType == 'quiz') { + cacheKey = 'quizzes_${req.classId}'; + } else if (req.entityType == 'class') { + cacheKey = 'classes'; + } else if (req.entityType == 'grade_sheet') { + cacheKey = 'grade_sheets_${req.classId}'; + } else if (req.entityType == 'staff') { + cacheKey = 'staff'; + } else if (req.entityType == 'student') { + cacheKey = 'students'; + } + + if (cacheKey.isNotEmpty && req.method != 'DELETE') { + var cachedData = await cache.getCachedData(cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id']?.toString() == req.tempId); + await cache.cacheData(cacheKey, list); + _syncEventController.add(null); + } + } + } + + @override + void onInternetConnected() { + _attemptSync(); + } +} diff --git a/mobile/lib/services/remote_quiz_service.dart b/mobile/lib/services/remote_quiz_service.dart index f1d148c8..8b77ca03 100644 --- a/mobile/lib/services/remote_quiz_service.dart +++ b/mobile/lib/services/remote_quiz_service.dart @@ -1,31 +1,33 @@ import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; import 'package:yaspo_mobile/abstract/quiz_service.dart'; -import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/exceptions/quiz_exception.dart'; import 'package:yaspo_mobile/model/quiz/question.dart'; import 'package:yaspo_mobile/model/quiz/quiz.dart'; import 'package:yaspo_mobile/model/quiz/submission.dart'; import 'package:yaspo_mobile/abstract/local_cache_service.dart'; -import 'package:yaspo_mobile/exceptions/network_exception.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; +import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; class RemoteQuizService implements QuizService { - final ConnectivityBaseService _connectivity = GetIt.I(); final LocalCacheBaseService _cache = GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); final String _cacheKey = 'quizzes'; @override Future> getQuizzes([String classId = 'single']) async { + String cacheKey = '${_cacheKey}_$classId'; try { var response = await authenticatedDio.get('/classes/$classId/quizzes'); if (response.statusCode == 200) { var data = response.data as List; - await _cache.cacheData(_cacheKey, data); + await _cache.cacheData(cacheKey, data); return data.map((e) => Quiz.fromJson(e)).toList(); } } catch (_) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { return (cachedData as List) .map((e) => Quiz.fromJson(Map.from(e))) @@ -37,17 +39,18 @@ class RemoteQuizService implements QuizService { @override Future getQuiz(String id, [String classId = 'single']) async { + String cacheKey = '${_cacheKey}_${classId}_$id'; try { var response = await authenticatedDio.get( '/classes/$classId/quizzes/$id', ); if (response.statusCode == 200) { - await _cache.cacheData('${_cacheKey}_$id', response.data); + await _cache.cacheData(cacheKey, response.data); return Quiz.fromJson(response.data); } throw QuizNotFound(); } catch (e) { - var cachedData = await _cache.getCachedData('${_cacheKey}_$id'); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { return Quiz.fromJson(Map.from(cachedData)); } @@ -63,7 +66,7 @@ class RemoteQuizService implements QuizService { String quizId, [ String classId = 'single', ]) async { - var cacheKey = '${_cacheKey}_${quizId}_submissions'; + var cacheKey = '${_cacheKey}_${classId}_${quizId}_submissions'; try { var response = await authenticatedDio.get( '/classes/$classId/quizzes/$quizId/submissions', @@ -90,7 +93,7 @@ class RemoteQuizService implements QuizService { String submissionId, [ String classId = 'single', ]) async { - var cacheKey = '${_cacheKey}_${quizId}_submission_$submissionId'; + var cacheKey = '${_cacheKey}_${classId}_${quizId}_submission_$submissionId'; try { var response = await authenticatedDio.get( '/classes/$classId/quizzes/$quizId/submissions/$submissionId', @@ -119,10 +122,7 @@ class RemoteQuizService implements QuizService { List questions, [ String classId = 'single', ]) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } - + String cacheKey = '${_cacheKey}_$classId'; var data = { 'title': title, 'is_published': isPublished, @@ -132,6 +132,41 @@ class RemoteQuizService implements QuizService { 'questions': questions.map((e) => e.toJson()).toList(), }; + if (!await _connectivity.hasInternet()) { + String tempId = 'pending_${DateTime.now().millisecondsSinceEpoch}'; + int totalPoints = questions.fold(0, (sum, q) => sum + q.points); + + var optimistic = Quiz( + id: tempId, + title: title, + totalPoints: totalPoints, + isPublished: isPublished, + startsAt: startsAt, + endsAt: endsAt, + timeLimitInMinutes: timeLimitInMinutes, + questions: questions, + isPending: true, + ); + + var cachedData = await _cache.getCachedData(cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(optimistic.toJson()); + await _cache.cacheData(cacheKey, list); + + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: tempId, + method: 'PUT', + url: '/classes/$classId/quizzes', + data: data, + entityType: 'quiz', + classId: classId, + ), + ); + + return tempId; + } + try { var response = await authenticatedDio.put( '/classes/$classId/quizzes', @@ -141,7 +176,7 @@ class RemoteQuizService implements QuizService { String id = response.data['id']; int totalPoints = questions.fold(0, (sum, q) => sum + q.points); - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); List list = cachedData != null ? List.from(cachedData) : []; var newQuiz = Quiz( @@ -156,7 +191,7 @@ class RemoteQuizService implements QuizService { ); list.add(newQuiz.toJson()); - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); return id; } @@ -177,10 +212,7 @@ class RemoteQuizService implements QuizService { List questions, [ String classId = 'single', ]) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } - + String cacheKey = '${_cacheKey}_$classId'; var data = { 'title': title, 'is_published': isPublished, @@ -190,16 +222,49 @@ class RemoteQuizService implements QuizService { 'questions': questions.map((e) => e.toJson()).toList(), }; + if (!await _connectivity.hasInternet()) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == id); + if (index != -1) { + int totalPoints = questions.fold(0, (sum, q) => sum + q.points); + var item = Map.from(list[index]); + item['title'] = title; + item['is_published'] = isPublished; + item['starts_at'] = startsAt.toIso8601String(); + item['ends_at'] = endsAt.toIso8601String(); + item['time_limit_in_minutes'] = timeLimitInMinutes; + item['total_points'] = totalPoints; + item['questions'] = questions.map((e) => e.toJson()).toList(); + item['isPending'] = true; + list[index] = item; + await _cache.cacheData(cacheKey, list); + } + } + + if (!id.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: id, + method: 'PATCH', + url: '/classes/$classId/quizzes/$id', + data: data, + entityType: 'quiz', + classId: classId, + ), + ); + } + return; + } + try { var response = await authenticatedDio.patch( '/classes/$classId/quizzes/$id', data: data, ); - if (response.statusCode != 200) { - throw QuizUpdateFailed(); - } if (response.statusCode == 200) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { List list = List.from(cachedData); int index = list.indexWhere((e) => e['id'] == id); @@ -214,7 +279,7 @@ class RemoteQuizService implements QuizService { item['total_points'] = totalPoints; item['questions'] = questions.map((e) => e.toJson()).toList(); list[index] = item; - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); } } return; @@ -233,19 +298,41 @@ class RemoteQuizService implements QuizService { @override Future deleteQuiz(String id, [String classId = 'single']) async { + String cacheKey = '${_cacheKey}_$classId'; + if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == id); + await _cache.cacheData(cacheKey, list); + } + + if (!id.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: id, + method: 'DELETE', + url: '/classes/$classId/quizzes/$id', + data: {}, + entityType: 'quiz', + classId: classId, + ), + ); + } + return; } + try { var response = await authenticatedDio.delete( '/classes/$classId/quizzes/$id', ); if (response.statusCode == 200) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { List list = List.from(cachedData); list.removeWhere((e) => e['id'] == id); - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); } return; } @@ -260,16 +347,13 @@ class RemoteQuizService implements QuizService { @override Future startQuiz(String quizId, [String classId = 'single']) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } - + String cacheKey = '${_cacheKey}_$classId'; try { var response = await authenticatedDio.post( '/classes/$classId/quizzes/$quizId/start', ); if (response.statusCode == 200) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { List list = List.from(cachedData); int index = list.indexWhere((e) => e['id'] == quizId); @@ -277,7 +361,7 @@ class RemoteQuizService implements QuizService { var item = Map.from(list[index]); item['has_taken'] = true; list[index] = item; - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); } } return Quiz.fromJson(response.data); @@ -296,18 +380,18 @@ class RemoteQuizService implements QuizService { @override Future submitQuiz( - String quizId, List> answers, [String classId = 'single'] - ) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } + String quizId, + List> answers, [ + String classId = 'single', + ]) async { + String cacheKey = '${_cacheKey}_$classId'; try { var response = await authenticatedDio.post( '/classes/$classId/quizzes/$quizId/submit', data: {'answers': answers}, ); if (response.statusCode == 200) { - var cachedData = await _cache.getCachedData(_cacheKey); + var cachedData = await _cache.getCachedData(cacheKey); if (cachedData != null) { List list = List.from(cachedData); int index = list.indexWhere((e) => e['id'] == quizId); @@ -315,7 +399,7 @@ class RemoteQuizService implements QuizService { var item = Map.from(list[index]); item['has_taken'] = true; list[index] = item; - await _cache.cacheData(_cacheKey, list); + await _cache.cacheData(cacheKey, list); } } return QuizResult.fromJson(response.data); @@ -331,7 +415,7 @@ class RemoteQuizService implements QuizService { String quizId, [ String classId = 'single', ]) async { - var cacheKey = '${_cacheKey}_${quizId}_score'; + var cacheKey = '${_cacheKey}_${classId}_${quizId}_score'; try { var response = await authenticatedDio.get( '/classes/$classId/quizzes/$quizId/score', diff --git a/mobile/lib/services/remote_staff_service.dart b/mobile/lib/services/remote_staff_service.dart index 63e4e574..c542205a 100644 --- a/mobile/lib/services/remote_staff_service.dart +++ b/mobile/lib/services/remote_staff_service.dart @@ -1,20 +1,20 @@ import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; -import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/abstract/staff_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/exceptions/staff_exception.dart'; import 'package:yaspo_mobile/exceptions/student_exception.dart'; import 'package:yaspo_mobile/model/class/class_model.dart'; import 'package:yaspo_mobile/model/staff/staff.dart'; import 'package:yaspo_mobile/abstract/local_cache_service.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; +import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; class RemoteStaffService extends StaffService { - final ConnectivityBaseService _connectivity = - GetIt.I(); final LocalCacheBaseService _cache = GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); final String _cacheKey = 'staff'; @override @@ -60,9 +60,37 @@ class RemoteStaffService extends StaffService { data['password'] = password; data['send_instructions'] = sendInstructions; } + if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); + String tempId = 'pending_${DateTime.now().millisecondsSinceEpoch}'; + var optimistic = Staff( + id: tempId, + name: name, + email: email, + permissions: permissions ?? [], + createdAt: DateTime.now().toString(), + isPending: true, + ); + + var cachedData = await _cache.getCachedData(_cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(optimistic.toJson()); + await _cache.cacheData(_cacheKey, list); + + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: tempId, + method: 'PUT', + url: '/staff', + data: data, + entityType: 'staff', + classId: 'all', + ), + ); + + return optimistic; } + try { var response = await authenticatedDio.put('/staff', data: data); if (response.statusCode == 200) { @@ -74,7 +102,6 @@ class RemoteStaffService extends StaffService { createdAt: DateTime.now().toString(), ); - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); List list = cachedData != null ? List.from(cachedData) : []; list.add(newStaff.toJson()); @@ -95,12 +122,31 @@ class RemoteStaffService extends StaffService { @override Future deleteStaff(String id) async { if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == id); + await _cache.cacheData(_cacheKey, list); + } + + if (!id.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: id, + method: 'DELETE', + url: '/staff/$id', + data: {}, + entityType: 'staff', + classId: 'all', + ), + ); + } + return; } + var response = await authenticatedDio.delete('/staff/$id'); if (response.statusCode == 200) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -119,9 +165,6 @@ class RemoteStaffService extends StaffService { String? email, List? permissions, }) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } Map data = {}; if (name != null) data['name'] = name; if (email != null) data['email'] = email; @@ -129,11 +172,43 @@ class RemoteStaffService extends StaffService { data['permissions'] = permissions.map((e) => e.name).toList(); } + if (!await _connectivity.hasInternet()) { + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == id); + if (index != -1) { + var item = Map.from(list[index]); + if (name != null) item['name'] = name; + if (email != null) item['email'] = email; + if (permissions != null) { + item['permissions'] = permissions.map((e) => e.name).toList(); + } + item['isPending'] = true; + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } + + if (!id.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: id, + method: 'PATCH', + url: '/staff/$id', + data: data, + entityType: 'staff', + classId: 'all', + ), + ); + } + return; + } + try { var response = await authenticatedDio.patch('/staff/$id', data: data); if (response.statusCode == 200) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -167,9 +242,6 @@ class RemoteStaffService extends StaffService { String? password, bool? sendInstructions, ) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } Map data = {'autogenerate_password': autoGenerate}; if (!autoGenerate) { data['password'] = password; @@ -209,10 +281,21 @@ class RemoteStaffService extends StaffService { @override Future> getStaffClasses(String staffId) async { - var response = await authenticatedDio.get('/staff/$staffId/classes'); - if (response.statusCode == 200) { - List data = response.data; - return data.map((e) => ClassModel.fromJson(e)).toList(); + String cacheKey = 'staff_${staffId}_classes'; + try { + var response = await authenticatedDio.get('/staff/$staffId/classes'); + if (response.statusCode == 200) { + List data = response.data; + await _cache.cacheData(cacheKey, data); + return data.map((e) => ClassModel.fromJson(e)).toList(); + } + } catch (_) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => ClassModel.fromJson(Map.from(e))) + .toList(); + } } return []; } @@ -225,6 +308,8 @@ class RemoteStaffService extends StaffService { data: {'classIds': classIds}, ); if (response.statusCode == 200) { + // Clear classes cache + await _cache.clearCache('staff_${id}_classes'); return; } } catch (e) { diff --git a/mobile/lib/services/remote_student_service.dart b/mobile/lib/services/remote_student_service.dart index 13e0ea8c..73847c0d 100644 --- a/mobile/lib/services/remote_student_service.dart +++ b/mobile/lib/services/remote_student_service.dart @@ -1,19 +1,19 @@ import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; -import 'package:yaspo_mobile/abstract/connectivity_service.dart'; import 'package:yaspo_mobile/abstract/student_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; -import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/exceptions/staff_exception.dart'; import 'package:yaspo_mobile/exceptions/student_exception.dart'; import 'package:yaspo_mobile/model/class/class_model.dart'; import 'package:yaspo_mobile/model/student/student.dart'; import 'package:yaspo_mobile/abstract/local_cache_service.dart'; +import 'package:yaspo_mobile/abstract/connectivity_service.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; +import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; class RemoteStudentService extends StudentService { - final ConnectivityBaseService _connectivity = - GetIt.I(); final LocalCacheBaseService _cache = GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); final String _cacheKey = 'students'; @override @@ -44,10 +44,6 @@ class RemoteStudentService extends StudentService { String? password, bool? sendInstructions, ) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } - Map data = { 'name': name, 'email': email, @@ -57,6 +53,36 @@ class RemoteStudentService extends StudentService { data['password'] = password; data["send_instructions"] = sendInstructions; } + + if (!await _connectivity.hasInternet()) { + String tempId = 'pending_${DateTime.now().millisecondsSinceEpoch}'; + var optimistic = Student( + id: tempId, + name: name, + email: email, + createdAt: DateTime.now().toIso8601String(), + isPending: true, + ); + + var cachedData = await _cache.getCachedData(_cacheKey); + List list = cachedData != null ? List.from(cachedData) : []; + list.add(optimistic.toJson()); + await _cache.cacheData(_cacheKey, list); + + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: tempId, + method: 'PUT', + url: '/students', + data: data, + entityType: 'student', + classId: 'all', + ), + ); + + return optimistic; + } + try { var response = await authenticatedDio.put('/students', data: data); if (response.statusCode == 200) { @@ -67,7 +93,6 @@ class RemoteStudentService extends StudentService { createdAt: DateTime.now().toIso8601String(), ); - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); List list = cachedData != null ? List.from(cachedData) : []; list.add(newStudent.toJson()); @@ -125,13 +150,31 @@ class RemoteStudentService extends StudentService { @override Future deleteStudent(String id) async { if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + list.removeWhere((e) => e['id'] == id); + await _cache.cacheData(_cacheKey, list); + } + + if (!id.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: id, + method: 'DELETE', + url: '/students/$id', + data: {}, + entityType: 'student', + classId: 'all', + ), + ); + } + return; } var response = await authenticatedDio.delete('/students/$id'); if (response.statusCode == 200) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -145,15 +188,41 @@ class RemoteStudentService extends StudentService { @override Future updateStudentInfo(String id, String name, String email) async { + Map data = {'name': name, 'email': email}; + if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); + var cachedData = await _cache.getCachedData(_cacheKey); + if (cachedData != null) { + List list = List.from(cachedData); + int index = list.indexWhere((e) => e['id'] == id); + if (index != -1) { + var item = Map.from(list[index]); + item['name'] = name; + item['email'] = email; + item['isPending'] = true; + list[index] = item; + await _cache.cacheData(_cacheKey, list); + } + } + + if (!id.startsWith('pending_')) { + await GetIt.I().enqueueRequest( + OfflineSyncRequest( + tempId: id, + method: 'PATCH', + url: '/students/$id', + data: data, + entityType: 'student', + classId: 'all', + ), + ); + } + return; } - Map data = {'name': name, 'email': email}; try { var response = await authenticatedDio.patch('/students/$id', data: data); if (response.statusCode == 200) { - // Update Cache var cachedData = await _cache.getCachedData(_cacheKey); if (cachedData != null) { List list = List.from(cachedData); @@ -184,10 +253,6 @@ class RemoteStudentService extends StudentService { String? password, bool? sendInstructions, ) async { - if (!await _connectivity.hasInternet()) { - throw OfflineException('No internet connection'); - } - Map data = {'autogenerate_password': autoGenerate}; if (!autoGenerate) { data['password'] = password; @@ -212,12 +277,18 @@ class RemoteStudentService extends StudentService { @override Future getStudentById(String id) async { + String cacheKey = '${_cacheKey}_$id'; try { var response = await authenticatedDio.get('/students/$id'); if (response.statusCode == 200) { + await _cache.cacheData(cacheKey, response.data); return Student.fromJson(response.data); } } on DioException catch (e) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + return Student.fromJson(Map.from(cachedData)); + } if (e.response?.statusCode == 404) { throw UserNotFoundException(); } @@ -229,15 +300,21 @@ class RemoteStudentService extends StudentService { @override Future> getStudentClasses(String studentId) async { + String cacheKey = 'student_${studentId}_classes'; try { var response = await authenticatedDio.get('/students/$studentId/classes'); if (response.statusCode == 200) { - return (response.data as List) - .map((e) => ClassModel.fromJson(e)) + List data = response.data; + await _cache.cacheData(cacheKey, data); + return data.map((e) => ClassModel.fromJson(e)).toList(); + } + } catch (_) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => ClassModel.fromJson(Map.from(e))) .toList(); } - } catch (e) { - rethrow; } return []; } @@ -253,6 +330,8 @@ class RemoteStudentService extends StudentService { data: {'classIds': classIds}, ); if (response.statusCode == 200) { + // Clear classes cache + await _cache.clearCache('student_${studentId}_classes'); return; } } catch (e) { diff --git a/mobile/lib/services/remote_user_service.dart b/mobile/lib/services/remote_user_service.dart index 73a914e7..ff71c62f 100644 --- a/mobile/lib/services/remote_user_service.dart +++ b/mobile/lib/services/remote_user_service.dart @@ -1,4 +1,6 @@ import 'package:dio/dio.dart'; +import 'package:get_it/get_it.dart'; +import 'package:yaspo_mobile/abstract/local_cache_service.dart'; import 'package:yaspo_mobile/abstract/user_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; import 'package:yaspo_mobile/exceptions/staff_exception.dart'; @@ -6,6 +8,8 @@ import 'package:yaspo_mobile/model/class/class_model.dart'; import 'package:yaspo_mobile/model/user/user.dart'; class RemoteUserService extends UserService { + final LocalCacheBaseService _cache = GetIt.I(); + @override Future updateSelfProfile(String name) async { Map data = {'name': name}; @@ -26,12 +30,18 @@ class RemoteUserService extends UserService { @override Future getUserById(String id) async { + String cacheKey = 'user_$id'; try { var response = await authenticatedDio.get('/users/$id'); if (response.statusCode == 200) { + await _cache.cacheData(cacheKey, response.data); return User.fromJson(response.data); } } on DioException catch (e) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + return User.fromJson(Map.from(cachedData)); + } if (e.response?.statusCode == 404) { throw UserNotFoundException(); } @@ -43,13 +53,21 @@ class RemoteUserService extends UserService { @override Future> getMyClasses() async { + String cacheKey = 'my_classes'; try { var response = await authenticatedDio.get('/users/me/classes'); if (response.statusCode == 200) { List data = response.data; + await _cache.cacheData(cacheKey, data); return data.map((e) => ClassModel.fromJson(e)).toList(); } } on DioException catch (e) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => ClassModel.fromJson(Map.from(e))) + .toList(); + } if (e.response?.statusCode == 404) { throw UserNotFoundException(); } diff --git a/mobile/lib/ui/app/organization/staff/classes/staff_class_widget.dart b/mobile/lib/ui/app/organization/staff/classes/staff_class_widget.dart index d724012e..78d7fbee 100644 --- a/mobile/lib/ui/app/organization/staff/classes/staff_class_widget.dart +++ b/mobile/lib/ui/app/organization/staff/classes/staff_class_widget.dart @@ -199,49 +199,62 @@ class StaffClassWidget extends StatelessWidget { builder: (context, user) { bool isManager = user?.permissions.contains(Permission.MANAGE_CLASSES) ?? false; bool isEducator = user?.permissions.contains(Permission.EDUCATOR) ?? false; - bool isLocked = !isAssigned || !isEducator; - return Row( - mainAxisSize: MainAxisSize.min, + return Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (isLocked) - Icon( - Icons.lock_outline, - color: Theme.of(context).colorScheme.outline, - size: 20, - ), - if (isManager) ...[ - if (isLocked) const SizedBox(width: 8), - OptionsItem( - iconKey: Key("${cls.id}-options"), - options: [ - OptionTile( - optionKey: Key("${cls.id}-update"), - label: 'Update Class', - icon: Icons.edit_outlined, - onTap: () { - Navigator.of(context).pop(); - _updateClass(context); - }, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLocked) + Icon( + Icons.lock_outline, + color: Theme.of(context).colorScheme.outline, + size: 20, ), - OptionTile( - optionKey: Key("${cls.id}-delete"), - label: 'Delete Class', - icon: Icons.delete, - onTap: () { - Navigator.of(context).pop(); - _deleteClass(context); - }, + if (isManager) ...[ + if (isLocked) const SizedBox(width: 8), + OptionsItem( + iconKey: Key("${cls.id}-options"), + options: [ + OptionTile( + optionKey: Key("${cls.id}-update"), + label: 'Update Class', + icon: Icons.edit_outlined, + onTap: () { + Navigator.of(context).pop(); + _updateClass(context); + }, + ), + OptionTile( + optionKey: Key("${cls.id}-delete"), + label: 'Delete Class', + icon: Icons.delete, + onTap: () { + Navigator.of(context).pop(); + _deleteClass(context); + }, + ), + ], ), - ], - ), - ] else if (!isLocked) - Icon( - Icons.chevron_right, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.3), + ] else if (!isLocked) + Icon( + Icons.chevron_right, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.3), + ), + ], + ), + if (cls.isPending) + Padding( + padding: const EdgeInsets.only(right: 12.0), + child: Icon( + Icons.schedule_rounded, + size: 16, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), ), ], ); diff --git a/mobile/lib/ui/app/organization/staff/staff/staff_widget.dart b/mobile/lib/ui/app/organization/staff/staff/staff_widget.dart index 20dab77e..8214291e 100644 --- a/mobile/lib/ui/app/organization/staff/staff/staff_widget.dart +++ b/mobile/lib/ui/app/organization/staff/staff/staff_widget.dart @@ -53,6 +53,19 @@ class StaffWidget extends StatelessWidget { ), ), ), + if (staff.isPending) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Icon( + Icons.schedule_rounded, + size: 18, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), ], ), ), diff --git a/mobile/lib/ui/app/organization/staff/students/student_widget.dart b/mobile/lib/ui/app/organization/staff/students/student_widget.dart index b6be0300..4ffaa8ae 100644 --- a/mobile/lib/ui/app/organization/staff/students/student_widget.dart +++ b/mobile/lib/ui/app/organization/staff/students/student_widget.dart @@ -53,6 +53,19 @@ class StudentWidget extends StatelessWidget { ), ), ), + if (student.isPending) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Icon( + Icons.schedule_rounded, + size: 18, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), ], ), ), diff --git a/mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart b/mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart index aaaf4809..a484b21b 100644 --- a/mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart @@ -85,7 +85,7 @@ class _AddAnnouncementScreenState extends State { ); } } finally { - setState(() => isSubmitting = false); + if (mounted) setState(() => isSubmitting = false); } } diff --git a/mobile/lib/ui/app/single-class/staff/announcements/announcement_widget.dart b/mobile/lib/ui/app/single-class/staff/announcements/announcement_widget.dart index 7bc79b49..693f2417 100644 --- a/mobile/lib/ui/app/single-class/staff/announcements/announcement_widget.dart +++ b/mobile/lib/ui/app/single-class/staff/announcements/announcement_widget.dart @@ -74,76 +74,90 @@ class AnnouncementWidget extends StatelessWidget { ], ), ), - BlocBuilder( - builder: (BuildContext context, User? user) { - if (user == null) { - return const SizedBox.shrink(); - } - if (user.permissions.contains(Permission.EDUCATOR)) { - return OptionsItem( - iconKey: Key("${announcement.id}-options"), - options: [ - OptionTile( - optionKey: Key("${announcement.id}-delete"), - label: 'Delete Announcement', - icon: Icons.delete, - onTap: () async { - var confirm = await showCustomDialog( - context: context, - title: - "Are you sure you want to delete this announcement?", - message: "This action cannot be undone.", - confirmText: "Delete", - ); - if (confirm == null || !confirm.$1) return; - try { - await GetIt.I() - .deleteAnnouncement( - announcement.id, - classId, - ); - await GetIt.I().trackEvent( - 'delete_announcements', - parameters: {'status': 'success'}, - ); - onDelete(); - if (context.mounted) { - showTimedModalNotice( + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + BlocBuilder( + builder: (BuildContext context, User? user) { + if (user == null) { + return const SizedBox.shrink(); + } + if (user.permissions.contains(Permission.EDUCATOR)) { + return OptionsItem( + iconKey: Key("${announcement.id}-options"), + options: [ + OptionTile( + optionKey: Key("${announcement.id}-delete"), + label: 'Delete Announcement', + icon: Icons.delete, + onTap: () async { + var confirm = await showCustomDialog( context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of( - context, - ).colorScheme.tertiary, - text: "Announcement deleted successfully", - onComplete: (_) {}, + title: + "Are you sure you want to delete this announcement?", + message: "This action cannot be undone.", + confirmText: "Delete", ); - } - } catch (e) { - await GetIt.I().trackEvent( - 'delete_announcements', - parameters: {'status': 'failed'}, - ); - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of( - context, - ).colorScheme.primary, - text: "Failed to delete announcement", - onComplete: (_) {}, - ); - } - } - }, - ), - ], - ); - } - return SizedBox(); - }, + if (confirm == null || !confirm.$1) return; + try { + await GetIt.I() + .deleteAnnouncement( + announcement.id, + classId, + ); + await GetIt.I().trackEvent( + 'delete_announcements', + parameters: {'status': 'success'}, + ); + onDelete(); + if (context.mounted) { + showTimedModalNotice( + context: context, + duration: const Duration(seconds: 3), + icon: Icons.check_circle_outline, + iconColor: Theme.of( + context, + ).colorScheme.tertiary, + text: "Announcement deleted successfully", + onComplete: (_) {}, + ); + } + } catch (e) { + await GetIt.I().trackEvent( + 'delete_announcements', + parameters: {'status': 'failed'}, + ); + if (context.mounted) { + showTimedModalNotice( + context: context, + duration: const Duration(seconds: 3), + icon: Icons.error_outline_outlined, + iconColor: Theme.of( + context, + ).colorScheme.primary, + text: "Failed to delete announcement", + onComplete: (_) {}, + ); + } + } + }, + ), + ], + ); + } + return SizedBox(); + }, + ), + if (announcement.isPending) + Padding( + padding: const EdgeInsets.only(right: 12.0), + child: Icon( + Icons.schedule_rounded, + size: 16, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], ), ], ), diff --git a/mobile/lib/ui/app/single-class/staff/announcements/announcements_screen.dart b/mobile/lib/ui/app/single-class/staff/announcements/announcements_screen.dart index 7d357998..6c35f20a 100644 --- a/mobile/lib/ui/app/single-class/staff/announcements/announcements_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/announcements/announcements_screen.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; import 'package:yaspo_mobile/abstract/analytics_service.dart'; import 'package:yaspo_mobile/abstract/announcement_service.dart'; import 'package:yaspo_mobile/model/announcement/announcement.dart'; @@ -20,11 +22,21 @@ class _AnnouncementScreenState extends State { bool loading = false; bool error = false; List announcements = []; + StreamSubscription? _syncSub; @override void initState() { super.initState(); fetchAnnouncements(); + _syncSub = GetIt.I().onSyncComplete.listen((_) { + if (mounted) fetchAnnouncements(); + }); + } + + @override + void dispose() { + _syncSub?.cancel(); + super.dispose(); } Future fetchAnnouncements() async { diff --git a/mobile/lib/ui/app/single-class/staff/assignments/assignment_widget.dart b/mobile/lib/ui/app/single-class/staff/assignments/assignment_widget.dart index 713e3c4f..5ba3dc47 100644 --- a/mobile/lib/ui/app/single-class/staff/assignments/assignment_widget.dart +++ b/mobile/lib/ui/app/single-class/staff/assignments/assignment_widget.dart @@ -92,86 +92,100 @@ class AssignmentWidget extends StatelessWidget { ), ), const SizedBox(width: 8), - BlocBuilder( - builder: (context, user) { - if (user == null) return const SizedBox.shrink(); - if (!user.permissions.contains(Permission.EDUCATOR)) { - return const SizedBox.shrink(); - } - return OptionsItem( - iconKey: Key('${assignment.id}-options'), - options: [ - OptionTile( - optionKey: Key('${assignment.id}-delete'), - label: 'Delete Assignment', - icon: Icons.delete, - onTap: () async { - var confirm = await showCustomDialog( - context: context, - title: - 'Are you sure you want to delete this assignment?', - message: 'This action cannot be undone.', - confirmText: 'Delete', - ); - if (confirm == null || !confirm.$1) return; - try { - await GetIt.I().deleteAssignment( - assignment.id, - classId, - ); - onDelete(); - await GetIt.I().trackEvent( - 'delete_assignment', - parameters: {'status': 'success'}, - ); - if (context.mounted) { - showTimedModalNotice( + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + BlocBuilder( + builder: (context, user) { + if (user == null) return const SizedBox.shrink(); + if (!user.permissions.contains(Permission.EDUCATOR)) { + return const SizedBox.shrink(); + } + return OptionsItem( + iconKey: Key('${assignment.id}-options'), + options: [ + OptionTile( + optionKey: Key('${assignment.id}-delete'), + label: 'Delete Assignment', + icon: Icons.delete, + onTap: () async { + var confirm = await showCustomDialog( context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: 'Assignment deleted successfully', - onComplete: (_) {}, + title: + 'Are you sure you want to delete this assignment?', + message: 'This action cannot be undone.', + confirmText: 'Delete', ); - } - } catch (_) { - await GetIt.I().trackEvent( - 'delete_assignment', - parameters: {'status': 'failed'}, - ); - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Failed to delete assignment', - onComplete: (_) {}, + if (confirm == null || !confirm.$1) return; + try { + await GetIt.I().deleteAssignment( + assignment.id, + classId, + ); + onDelete(); + await GetIt.I().trackEvent( + 'delete_assignment', + parameters: {'status': 'success'}, + ); + if (context.mounted) { + showTimedModalNotice( + context: context, + duration: const Duration(seconds: 3), + icon: Icons.check_circle_outline, + iconColor: Theme.of(context).colorScheme.tertiary, + text: 'Assignment deleted successfully', + onComplete: (_) {}, + ); + } + } catch (_) { + await GetIt.I().trackEvent( + 'delete_assignment', + parameters: {'status': 'failed'}, + ); + if (context.mounted) { + showTimedModalNotice( + context: context, + duration: const Duration(seconds: 3), + icon: Icons.error_outline_outlined, + iconColor: Theme.of(context).colorScheme.primary, + text: 'Failed to delete assignment', + onComplete: (_) {}, + ); + } + } + }, + ), + OptionTile( + optionKey: Key("${assignment.id}-update"), + label: 'Update Assignment', + icon: Icons.edit_outlined, + onTap: () async { + var updateAssignment = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + UpdateAssignmentScreen(assignments: assignment, classId: classId), + ), ); - } - } - }, - ), - OptionTile( - optionKey: Key("${assignment.id}-update"), - label: 'Update Assignment', - icon: Icons.edit_outlined, - onTap: () async { - var updateAssignment = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - UpdateAssignmentScreen(assignments: assignment, classId: classId), - ), - ); - if (updateAssignment != null && context.mounted) { - onUpdate(updateAssignment); - } - }, + if (updateAssignment != null && context.mounted) { + onUpdate(updateAssignment); + } + }, + ), + ], + ); + }, + ), + if (assignment.isPending) + Padding( + padding: const EdgeInsets.only(right: 12.0), + child: Icon( + Icons.schedule_rounded, + size: 16, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), ), - ], - ); - }, + ), + ], ), ], ), diff --git a/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart b/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart index b7902601..4f50b3ce 100644 --- a/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart @@ -184,7 +184,7 @@ class _CreateAssignmentScreenState extends State { ); } } finally { - setState(() => isSubmitting = false); + if (mounted) setState(() => isSubmitting = false); } } diff --git a/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart b/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart index ef3ee6b6..3b109635 100644 --- a/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart @@ -211,7 +211,7 @@ class _UpdateAssignmentScreenState extends State { ); } } finally { - setState(() => isSubmitting = false); + if (mounted) setState(() => isSubmitting = false); } } diff --git a/mobile/lib/ui/app/single-class/staff/class_settings/class_info_screen.dart b/mobile/lib/ui/app/single-class/staff/class_settings/class_info_screen.dart index 507c9897..3930ad47 100644 --- a/mobile/lib/ui/app/single-class/staff/class_settings/class_info_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/class_settings/class_info_screen.dart @@ -92,7 +92,7 @@ class _ClassInfoScreenState extends State { ); } } finally { - setState(() => isSubmitting = false); + if (mounted) setState(() => isSubmitting = false); } } diff --git a/mobile/lib/ui/app/single-class/staff/class_settings/delete_class_screen.dart b/mobile/lib/ui/app/single-class/staff/class_settings/delete_class_screen.dart index e4da93cd..8fb34ba6 100644 --- a/mobile/lib/ui/app/single-class/staff/class_settings/delete_class_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/class_settings/delete_class_screen.dart @@ -94,7 +94,7 @@ class _DeleteClassScreenState extends State { ); } } finally { - setState(() => isSubmitting = false); + if (mounted) setState(() => isSubmitting = false); } } diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_screen.dart index b4c6943f..4f56ec15 100644 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_screen.dart @@ -126,7 +126,7 @@ class _GradeSheetsState extends State { itemBuilder: (context, index) { var sheet = gradeSheets[index]; return SheetItemWidget( - text: sheet.name, + sheet: sheet, onTap: () { Navigator.push( context, diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/sheet_item_widget.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/sheet_item_widget.dart index 87ca150e..dfeed30a 100644 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/sheet_item_widget.dart +++ b/mobile/lib/ui/app/single-class/staff/grade_sheets/sheet_item_widget.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:yaspo_mobile/model/grade_sheets/grade_sheet.dart'; class SheetItemWidget extends StatelessWidget { - const SheetItemWidget({super.key, required this.text, required this.onTap}); + const SheetItemWidget({super.key, required this.sheet, required this.onTap}); - final String text; + final GradeSheet sheet; final VoidCallback onTap; @override @@ -23,14 +24,38 @@ class SheetItemWidget extends StatelessWidget { ), ), child: Padding( - padding: EdgeInsets.all(12), - child: Text( - text, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Expanded( + child: Text( + sheet.name, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + if (sheet.isPending) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Icon( + Icons.schedule_rounded, + size: 16, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + Icon( + Icons.chevron_right, + size: 20, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.3), + ), + ], ), ), ), diff --git a/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart b/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart index f13b3331..41bc205c 100644 --- a/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart @@ -17,7 +17,6 @@ import 'package:yaspo_mobile/ui/app/single-class/staff/profile/widgets/staff_per import 'package:yaspo_mobile/ui/app/single-class/staff/profile/widgets/update_info_form.dart'; import 'package:yaspo_mobile/ui/app/single-class/staff/profile/widgets/update_info_header.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; import 'package:yaspo_mobile/exceptions/network_exception.dart'; import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; import 'package:yaspo_mobile/values/spaces.dart'; diff --git a/mobile/lib/ui/app/single-class/staff/quizzes/quiz_widget.dart b/mobile/lib/ui/app/single-class/staff/quizzes/quiz_widget.dart index 57f2bb8b..342e2630 100644 --- a/mobile/lib/ui/app/single-class/staff/quizzes/quiz_widget.dart +++ b/mobile/lib/ui/app/single-class/staff/quizzes/quiz_widget.dart @@ -67,23 +67,41 @@ class QuizWidget extends StatelessWidget { ), ), const SizedBox(width: 10), - Text( - quiz.isPublished ? 'Published' : 'Draft', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: quiz.isPublished - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.tertiary, - ), - ), - const SizedBox(width: 8), - Icon( - Icons.arrow_forward_ios, - size: 16, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + children: [ + Text( + quiz.isPublished ? 'Published' : 'Draft', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: quiz.isPublished + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.tertiary, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward_ios, + size: 16, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ], + ), + if (quiz.isPending) + Padding( + padding: const EdgeInsets.only(right: 12.0, top: 4.0), + child: Icon( + Icons.schedule_rounded, + size: 16, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], ), ], ), diff --git a/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart b/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart index 426e15bd..eeb3ba71 100644 --- a/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart +++ b/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart @@ -53,6 +53,19 @@ class StaffWidget extends StatelessWidget { ), ), ), + if (staff.isPending) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Icon( + Icons.schedule_rounded, + size: 18, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), ], ), ), diff --git a/mobile/lib/ui/app/single-class/staff/students/student_widget.dart b/mobile/lib/ui/app/single-class/staff/students/student_widget.dart index 562477f8..d837a738 100644 --- a/mobile/lib/ui/app/single-class/staff/students/student_widget.dart +++ b/mobile/lib/ui/app/single-class/staff/students/student_widget.dart @@ -53,6 +53,19 @@ class StudentWidget extends StatelessWidget { ), ), ), + if (student.isPending) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Icon( + Icons.schedule_rounded, + size: 18, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), ], ), ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index b62cb00f..e1b244a2 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -177,6 +177,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" convert: dependency: transitive description: @@ -584,6 +600,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" json_annotation: dependency: transitive description: @@ -696,6 +728,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" objective_c: dependency: transitive description: @@ -732,10 +772,10 @@ packages: dependency: "direct overridden" description: name: path_provider_android - sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.23" + version: "2.3.1" path_provider_foundation: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 25fff4d0..a70d6d65 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: excel: ^4.0.6 path: ^1.9.1 build_runner: ^2.15.0 + connectivity_plus: ^7.1.1 dev_dependencies: From 5617c4bb63bf71975b7b90f22928dd9ce18ec835 Mon Sep 17 00:00:00 2001 From: Ahmed Helmy Date: Thu, 7 May 2026 19:56:14 +0300 Subject: [PATCH 8/9] fix the problem with test. --- mobile/lib/services/remote_staff_service.dart | 92 +------------------ .../lib/services/remote_student_service.dart | 88 +----------------- .../staff/staff/staff_widget.dart | 9 -- .../staff/students/student_widget.dart | 9 -- .../staff/staff/staff_widget.dart | 9 -- .../staff/students/student_widget.dart | 9 -- .../test/mocks/manual_offline_sync_mock.dart | 19 ++++ mobile/test/mocks/offline_sync.dart | 5 + .../add_announcement_page_test.dart | 4 + .../announcement/announcement_page_test.dart | 8 ++ .../login/screen/login_page_test.dart | 4 + 11 files changed, 44 insertions(+), 212 deletions(-) create mode 100644 mobile/test/mocks/manual_offline_sync_mock.dart create mode 100644 mobile/test/mocks/offline_sync.dart diff --git a/mobile/lib/services/remote_staff_service.dart b/mobile/lib/services/remote_staff_service.dart index c542205a..2978f044 100644 --- a/mobile/lib/services/remote_staff_service.dart +++ b/mobile/lib/services/remote_staff_service.dart @@ -8,13 +8,9 @@ import 'package:yaspo_mobile/exceptions/student_exception.dart'; import 'package:yaspo_mobile/model/class/class_model.dart'; import 'package:yaspo_mobile/model/staff/staff.dart'; import 'package:yaspo_mobile/abstract/local_cache_service.dart'; -import 'package:yaspo_mobile/abstract/connectivity_service.dart'; -import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; -import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; class RemoteStaffService extends StaffService { final LocalCacheBaseService _cache = GetIt.I(); - final ConnectivityBaseService _connectivity = GetIt.I(); final String _cacheKey = 'staff'; @override @@ -61,36 +57,6 @@ class RemoteStaffService extends StaffService { data['send_instructions'] = sendInstructions; } - if (!await _connectivity.hasInternet()) { - String tempId = 'pending_${DateTime.now().millisecondsSinceEpoch}'; - var optimistic = Staff( - id: tempId, - name: name, - email: email, - permissions: permissions ?? [], - createdAt: DateTime.now().toString(), - isPending: true, - ); - - var cachedData = await _cache.getCachedData(_cacheKey); - List list = cachedData != null ? List.from(cachedData) : []; - list.add(optimistic.toJson()); - await _cache.cacheData(_cacheKey, list); - - await GetIt.I().enqueueRequest( - OfflineSyncRequest( - tempId: tempId, - method: 'PUT', - url: '/staff', - data: data, - entityType: 'staff', - classId: 'all', - ), - ); - - return optimistic; - } - try { var response = await authenticatedDio.put('/staff', data: data); if (response.statusCode == 200) { @@ -113,6 +79,7 @@ class RemoteStaffService extends StaffService { if (e.response?.statusCode == 409) { throw EmailAlreadyExist(); } + rethrow; } catch (e) { rethrow; } @@ -121,29 +88,6 @@ class RemoteStaffService extends StaffService { @override Future deleteStaff(String id) async { - if (!await _connectivity.hasInternet()) { - var cachedData = await _cache.getCachedData(_cacheKey); - if (cachedData != null) { - List list = List.from(cachedData); - list.removeWhere((e) => e['id'] == id); - await _cache.cacheData(_cacheKey, list); - } - - if (!id.startsWith('pending_')) { - await GetIt.I().enqueueRequest( - OfflineSyncRequest( - tempId: id, - method: 'DELETE', - url: '/staff/$id', - data: {}, - entityType: 'staff', - classId: 'all', - ), - ); - } - return; - } - var response = await authenticatedDio.delete('/staff/$id'); if (response.statusCode == 200) { @@ -172,39 +116,6 @@ class RemoteStaffService extends StaffService { data['permissions'] = permissions.map((e) => e.name).toList(); } - if (!await _connectivity.hasInternet()) { - var cachedData = await _cache.getCachedData(_cacheKey); - if (cachedData != null) { - List list = List.from(cachedData); - int index = list.indexWhere((e) => e['id'] == id); - if (index != -1) { - var item = Map.from(list[index]); - if (name != null) item['name'] = name; - if (email != null) item['email'] = email; - if (permissions != null) { - item['permissions'] = permissions.map((e) => e.name).toList(); - } - item['isPending'] = true; - list[index] = item; - await _cache.cacheData(_cacheKey, list); - } - } - - if (!id.startsWith('pending_')) { - await GetIt.I().enqueueRequest( - OfflineSyncRequest( - tempId: id, - method: 'PATCH', - url: '/staff/$id', - data: data, - entityType: 'staff', - classId: 'all', - ), - ); - } - return; - } - try { var response = await authenticatedDio.patch('/staff/$id', data: data); @@ -230,6 +141,7 @@ class RemoteStaffService extends StaffService { if (e.response?.statusCode == 409) { throw EmailAlreadyExist(); } + rethrow; } catch (e) { rethrow; } diff --git a/mobile/lib/services/remote_student_service.dart b/mobile/lib/services/remote_student_service.dart index 73847c0d..5b9f9d53 100644 --- a/mobile/lib/services/remote_student_service.dart +++ b/mobile/lib/services/remote_student_service.dart @@ -7,13 +7,9 @@ import 'package:yaspo_mobile/exceptions/student_exception.dart'; import 'package:yaspo_mobile/model/class/class_model.dart'; import 'package:yaspo_mobile/model/student/student.dart'; import 'package:yaspo_mobile/abstract/local_cache_service.dart'; -import 'package:yaspo_mobile/abstract/connectivity_service.dart'; -import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; -import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; class RemoteStudentService extends StudentService { final LocalCacheBaseService _cache = GetIt.I(); - final ConnectivityBaseService _connectivity = GetIt.I(); final String _cacheKey = 'students'; @override @@ -54,35 +50,6 @@ class RemoteStudentService extends StudentService { data["send_instructions"] = sendInstructions; } - if (!await _connectivity.hasInternet()) { - String tempId = 'pending_${DateTime.now().millisecondsSinceEpoch}'; - var optimistic = Student( - id: tempId, - name: name, - email: email, - createdAt: DateTime.now().toIso8601String(), - isPending: true, - ); - - var cachedData = await _cache.getCachedData(_cacheKey); - List list = cachedData != null ? List.from(cachedData) : []; - list.add(optimistic.toJson()); - await _cache.cacheData(_cacheKey, list); - - await GetIt.I().enqueueRequest( - OfflineSyncRequest( - tempId: tempId, - method: 'PUT', - url: '/students', - data: data, - entityType: 'student', - classId: 'all', - ), - ); - - return optimistic; - } - try { var response = await authenticatedDio.put('/students', data: data); if (response.statusCode == 200) { @@ -104,6 +71,7 @@ class RemoteStudentService extends StudentService { if (e.response?.statusCode == 409) { throw EmailAlreadyExist(); } + rethrow; } catch (e) { rethrow; } @@ -149,29 +117,6 @@ class RemoteStudentService extends StudentService { @override Future deleteStudent(String id) async { - if (!await _connectivity.hasInternet()) { - var cachedData = await _cache.getCachedData(_cacheKey); - if (cachedData != null) { - List list = List.from(cachedData); - list.removeWhere((e) => e['id'] == id); - await _cache.cacheData(_cacheKey, list); - } - - if (!id.startsWith('pending_')) { - await GetIt.I().enqueueRequest( - OfflineSyncRequest( - tempId: id, - method: 'DELETE', - url: '/students/$id', - data: {}, - entityType: 'student', - classId: 'all', - ), - ); - } - return; - } - var response = await authenticatedDio.delete('/students/$id'); if (response.statusCode == 200) { @@ -190,36 +135,6 @@ class RemoteStudentService extends StudentService { Future updateStudentInfo(String id, String name, String email) async { Map data = {'name': name, 'email': email}; - if (!await _connectivity.hasInternet()) { - var cachedData = await _cache.getCachedData(_cacheKey); - if (cachedData != null) { - List list = List.from(cachedData); - int index = list.indexWhere((e) => e['id'] == id); - if (index != -1) { - var item = Map.from(list[index]); - item['name'] = name; - item['email'] = email; - item['isPending'] = true; - list[index] = item; - await _cache.cacheData(_cacheKey, list); - } - } - - if (!id.startsWith('pending_')) { - await GetIt.I().enqueueRequest( - OfflineSyncRequest( - tempId: id, - method: 'PATCH', - url: '/students/$id', - data: data, - entityType: 'student', - classId: 'all', - ), - ); - } - return; - } - try { var response = await authenticatedDio.patch('/students/$id', data: data); if (response.statusCode == 200) { @@ -241,6 +156,7 @@ class RemoteStudentService extends StudentService { if (e.response?.statusCode == 409) { throw EmailAlreadyExist(); } + rethrow; } catch (e) { rethrow; } diff --git a/mobile/lib/ui/app/organization/staff/staff/staff_widget.dart b/mobile/lib/ui/app/organization/staff/staff/staff_widget.dart index 8214291e..137ad508 100644 --- a/mobile/lib/ui/app/organization/staff/staff/staff_widget.dart +++ b/mobile/lib/ui/app/organization/staff/staff/staff_widget.dart @@ -53,15 +53,6 @@ class StaffWidget extends StatelessWidget { ), ), ), - if (staff.isPending) - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon( - Icons.schedule_rounded, - size: 18, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), Icon( Icons.chevron_right, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), diff --git a/mobile/lib/ui/app/organization/staff/students/student_widget.dart b/mobile/lib/ui/app/organization/staff/students/student_widget.dart index 4ffaa8ae..7a1ccd41 100644 --- a/mobile/lib/ui/app/organization/staff/students/student_widget.dart +++ b/mobile/lib/ui/app/organization/staff/students/student_widget.dart @@ -53,15 +53,6 @@ class StudentWidget extends StatelessWidget { ), ), ), - if (student.isPending) - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon( - Icons.schedule_rounded, - size: 18, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), Icon( Icons.chevron_right, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), diff --git a/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart b/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart index eeb3ba71..94c2a5bf 100644 --- a/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart +++ b/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart @@ -53,15 +53,6 @@ class StaffWidget extends StatelessWidget { ), ), ), - if (staff.isPending) - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon( - Icons.schedule_rounded, - size: 18, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), Icon( Icons.chevron_right, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), diff --git a/mobile/lib/ui/app/single-class/staff/students/student_widget.dart b/mobile/lib/ui/app/single-class/staff/students/student_widget.dart index d837a738..3746cd99 100644 --- a/mobile/lib/ui/app/single-class/staff/students/student_widget.dart +++ b/mobile/lib/ui/app/single-class/staff/students/student_widget.dart @@ -53,15 +53,6 @@ class StudentWidget extends StatelessWidget { ), ), ), - if (student.isPending) - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon( - Icons.schedule_rounded, - size: 18, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), Icon( Icons.chevron_right, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), diff --git a/mobile/test/mocks/manual_offline_sync_mock.dart b/mobile/test/mocks/manual_offline_sync_mock.dart new file mode 100644 index 00000000..fb8b3b48 --- /dev/null +++ b/mobile/test/mocks/manual_offline_sync_mock.dart @@ -0,0 +1,19 @@ +import 'dart:async'; +import 'package:mockito/mockito.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; +import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart'; + +class MockOfflineSyncService extends Mock implements OfflineSyncService { + @override + Stream get onSyncComplete => StreamController.broadcast().stream; + + @override + Future enqueueRequest(OfflineSyncRequest? request) async { + return; + } + + @override + void onInternetConnected() { + return; + } +} diff --git a/mobile/test/mocks/offline_sync.dart b/mobile/test/mocks/offline_sync.dart new file mode 100644 index 00000000..91257238 --- /dev/null +++ b/mobile/test/mocks/offline_sync.dart @@ -0,0 +1,5 @@ +import 'package:mockito/annotations.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; + +@GenerateMocks([OfflineSyncService]) +void main() {} diff --git a/mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart b/mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart index 7e29b400..dc5cb850 100644 --- a/mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart +++ b/mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart @@ -17,6 +17,8 @@ import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; import '../../../../../mocks/analytics.mocks.dart'; import '../../../../../mocks/announcement.mocks.dart'; +import '../../../../../mocks/manual_offline_sync_mock.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; void main() { late MockAnnouncementService mockAnnouncementService; @@ -74,6 +76,7 @@ void main() { GetIt.I.registerSingleton(mockAnnouncementService); GetIt.I.registerSingleton(mockAnalyticsService); + GetIt.I.registerSingleton(MockOfflineSyncService()); await setupWidget(tester); await tester.pumpAndSettle(); @@ -108,6 +111,7 @@ void main() { GetIt.I.registerSingleton(mockAnnouncementService); GetIt.I.registerSingleton(mockAnalyticsService); + GetIt.I.registerSingleton(MockOfflineSyncService()); await setupWidget(tester); await tester.pumpAndSettle(); diff --git a/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart b/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart index 7e193051..edff728a 100644 --- a/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart +++ b/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart @@ -17,6 +17,8 @@ import 'package:yaspo_mobile/ui/app/single-class/staff/announcements/announcemen import '../../../../../mocks/analytics.mocks.dart'; import '../../../../../mocks/announcement.mocks.dart'; +import '../../../../../mocks/manual_offline_sync_mock.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; void main() { late MockAnnouncementService mockAnnouncementService; @@ -80,6 +82,7 @@ void main() { GetIt.I.registerSingleton(mockAnnouncementService); GetIt.I.registerSingleton(mockAnalyticsService); + GetIt.I.registerSingleton(MockOfflineSyncService()); await setupWidget(tester, fakeUser: fakeUser); await tester.pumpAndSettle(); @@ -122,6 +125,7 @@ void main() { GetIt.I.registerSingleton(mockAnnouncementService); GetIt.I.registerSingleton(mockAnalyticsService); + GetIt.I.registerSingleton(MockOfflineSyncService()); await setupWidget(tester, fakeUser: fakeUser); await tester.pumpAndSettle(); @@ -156,6 +160,7 @@ void main() { GetIt.I.registerSingleton(mockAnnouncementService); GetIt.I.registerSingleton(mockAnalyticsService); + GetIt.I.registerSingleton(MockOfflineSyncService()); await setupWidget(tester, fakeUser: fakeUser); await tester.pumpAndSettle(); @@ -204,6 +209,7 @@ void main() { GetIt.I.registerSingleton(mockAnnouncementService); GetIt.I.registerSingleton(mockAnalyticsService); + GetIt.I.registerSingleton(MockOfflineSyncService()); await setupWidget(tester, fakeUser: fakeUser); await tester.pumpAndSettle(); @@ -261,6 +267,7 @@ void main() { GetIt.I.registerSingleton(mockAnnouncementService); GetIt.I.registerSingleton(mockAnalyticsService); + GetIt.I.registerSingleton(MockOfflineSyncService()); await setupWidget(tester, fakeUser: fakeUser); await tester.pumpAndSettle(); @@ -315,6 +322,7 @@ void main() { GetIt.I.registerSingleton(mockAnnouncementService); GetIt.I.registerSingleton(mockAnalyticsService); + GetIt.I.registerSingleton(MockOfflineSyncService()); await setupWidget(tester, fakeUser: fakeUser); await tester.pumpAndSettle(); diff --git a/mobile/test/ui/onboarding/login/screen/login_page_test.dart b/mobile/test/ui/onboarding/login/screen/login_page_test.dart index c37cf370..940e5b94 100644 --- a/mobile/test/ui/onboarding/login/screen/login_page_test.dart +++ b/mobile/test/ui/onboarding/login/screen/login_page_test.dart @@ -26,6 +26,8 @@ import '../../../../mocks/auth.mocks.dart'; import '../../../../mocks/credential_manager.mocks.dart'; import '../../../../mocks/notification.mocks.dart'; import '../../../../mocks/students.mocks.dart'; +import '../../../../mocks/manual_offline_sync_mock.dart'; +import 'package:yaspo_mobile/abstract/offline_sync_service.dart'; void main() { late MockAuthService mockAuthService; @@ -110,6 +112,7 @@ void main() { GetIt.I.registerSingleton(mockStudentService); GetIt.I.registerSingleton(mockAnalyticsService); GetIt.I.registerSingleton(mockCredentialManagerService); + GetIt.I.registerSingleton(MockOfflineSyncService()); await setupWidget(tester); @@ -151,6 +154,7 @@ void main() { GetIt.I.registerSingleton(mockNotificationService); GetIt.I.registerSingleton(mockAnalyticsService); GetIt.I.registerSingleton(mockCredentialManagerService); + GetIt.I.registerSingleton(MockOfflineSyncService()); await setupWidget(tester); From 715af9d5f26b9ba14c5ea22f3734dc5e53253c48 Mon Sep 17 00:00:00 2001 From: Ahmed Helmy Date: Sun, 10 May 2026 00:19:14 +0300 Subject: [PATCH 9/9] fix errors after rebase. --- mobile/lib/cubit/user/user_cubit.dart | 29 ----- mobile/lib/main.dart | 50 ++++---- .../services/remote_grade_sheet_service.dart | 119 +++++++++--------- .../students/bulk_import_students_screen.dart | 8 +- .../bulk_add_grade_records_screen.dart | 8 +- .../students/bulk_import_students_screen.dart | 8 +- mobile/pubspec.lock | 2 +- 7 files changed, 96 insertions(+), 128 deletions(-) diff --git a/mobile/lib/cubit/user/user_cubit.dart b/mobile/lib/cubit/user/user_cubit.dart index 436f06db..14e8a07b 100644 --- a/mobile/lib/cubit/user/user_cubit.dart +++ b/mobile/lib/cubit/user/user_cubit.dart @@ -1,9 +1,5 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/auth_service.dart'; -import 'package:yaspo_mobile/abstract/token_storage_service.dart'; import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/util/token_util.dart'; class UserCubit extends Cubit { UserCubit() : super(null); @@ -11,29 +7,4 @@ class UserCubit extends Cubit { void setUserModel(User? userModel) { emit(userModel); } - - Future refreshSession() async { - var tokenStorage = GetIt.I(); - var authService = GetIt.I(); - - var refreshToken = await tokenStorage.getRefreshToken(); - if (refreshToken == null || refreshToken.isEmpty) return; - - try { - var response = await authService.refresh(refreshToken); - - if (response.accessToken.isNotEmpty) { - String newRefreshToken = response.refreshToken.isNotEmpty - ? response.refreshToken - : refreshToken; - - await tokenStorage.setTokens(response.accessToken, newRefreshToken); - - var newUser = TokenUtil.decodeToken(response.accessToken); - emit(newUser); - } - } catch (e) { - rethrow; - } - } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index ffa21f51..358c61be 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -83,9 +83,7 @@ void main() async { GetIt.I.registerSingleton(RemoteUserService()); GetIt.I.registerSingleton(RemoteQuizService()); GetIt.I.registerSingleton(RemoteAnalyticsService()); - GetIt.I.registerSingleton( - LocalCredentialManagerService(), - ); + GetIt.I.registerSingleton(LocalCredentialManagerService()); GetIt.I.registerSingleton(RemoteClassService()); GetIt.I.registerSingleton(RemoteOfflineSyncService()); @@ -130,37 +128,39 @@ class _MyAppState extends State { } Future refreshUserToken() async { - var refreshToken = await GetIt.I().getRefreshToken(); - if (refreshToken == null) { - setState(() { - isLoading = false; - }); + var tokenStorage = GetIt.I(); + var authService = GetIt.I(); + var refreshToken = await tokenStorage.getRefreshToken(); + if (refreshToken == null || refreshToken.isEmpty) { + if (mounted) { + setState(() { + isLoading = false; + }); + } return; } try { - var response = await GetIt.I().refresh(refreshToken); + var response = await authService.refresh(refreshToken); - await GetIt.I().setTokens( - response.accessToken, - response.refreshToken, - ); + if (response.accessToken.isNotEmpty) { + String newRefreshToken = response.refreshToken.isNotEmpty ? response.refreshToken : refreshToken; - if (mounted) { - context.read().setUserModel( - TokenUtil.decodeToken(response.accessToken), - ); - } - } on InvalidCredentialException { - await GetIt.I().clearTokens(); + await tokenStorage.setTokens(response.accessToken, newRefreshToken); - if (mounted) context.read().setUserModel(null); - } catch (_) { + var newUser = TokenUtil.decodeToken(response.accessToken); + if (mounted) { + context.read().setUserModel(newUser); + } + } + } catch (_) { } finally { - setState(() { - isLoading = false; - }); + if (mounted) { + setState(() { + isLoading = false; + }); + } } } diff --git a/mobile/lib/services/remote_grade_sheet_service.dart b/mobile/lib/services/remote_grade_sheet_service.dart index 327f078b..4d1976ae 100644 --- a/mobile/lib/services/remote_grade_sheet_service.dart +++ b/mobile/lib/services/remote_grade_sheet_service.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; +import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; @@ -39,10 +41,10 @@ class RemoteGradeSheetService extends GradeSheetService { @override Future addGradeSheet( - String name, - bool visible, [ - String classId = 'single', - ]) async { + String name, + bool visible, [ + String classId = 'single', + ]) async { String cacheKey = '${_cacheKey}_$classId'; var data = {"name": name, "visible": visible}; @@ -99,9 +101,9 @@ class RemoteGradeSheetService extends GradeSheetService { @override Future deleteGradeSheet( - String gradeSheetId, [ - String classId = 'single', - ]) async { + String gradeSheetId, [ + String classId = 'single', + ]) async { String cacheKey = '${_cacheKey}_$classId'; if (!await _connectivity.hasInternet()) { @@ -151,10 +153,10 @@ class RemoteGradeSheetService extends GradeSheetService { @override Future updateGradeSheetVisibility( - String gradeSheetId, - bool visible, [ - String classId = 'single', - ]) async { + String gradeSheetId, + bool visible, [ + String classId = 'single', + ]) async { String cacheKey = '${_cacheKey}_$classId'; if (!await _connectivity.hasInternet()) { @@ -216,9 +218,9 @@ class RemoteGradeSheetService extends GradeSheetService { @override Future> getRecords( - String gradeSheetId, [ - String classId = 'single', - ]) async { + String gradeSheetId, [ + String classId = 'single', + ]) async { String recordsCacheKey = '${_cacheKey}_${classId}_${gradeSheetId}_records'; try { var response = await authenticatedDio.get( @@ -246,11 +248,11 @@ class RemoteGradeSheetService extends GradeSheetService { @override Future addStudentRecord( - String studentId, - String gradeSheetId, - String grade, [ - String classId = 'single', - ]) async { + String studentId, + String gradeSheetId, + String grade, [ + String classId = 'single', + ]) async { var data = {"student_id": studentId, "grade": grade}; try { var response = await authenticatedDio.put( @@ -277,47 +279,19 @@ class RemoteGradeSheetService extends GradeSheetService { if (e.response?.statusCode == 404) { throw NotGradeSheetFound(); } else { + log('Status code: ${e.response?.statusCode}'); rethrow; } } return GradeSheetRecord.empty(); } - @override - Future> bulkImportRecords( - String gradeSheetId, - List> recordsList, [ - String classId = 'single', - ]) async { - try { - var response = await authenticatedDio.post( - '/classes/$classId/grade-sheets/$gradeSheetId/records/bulk', - data: recordsList, - ); - - if (response.statusCode == 201) { - return (response.data["ids"] as List) - .map((id) => id.toString()) - .toList(); - } - } on DioException catch (e) { - if (e.response?.statusCode == 404) { - throw NotGradeSheetFound(); - } else if (e.response?.statusCode == 400) { - rethrow; - } else { - rethrow; - } - } - return []; - } - @override Future deleteStudentRecord( - String gradeSheetId, - String studentRecordId, [ - String classId = 'single', - ]) async { + String gradeSheetId, + String studentRecordId, [ + String classId = 'single', + ]) async { String recordsCacheKey = '${_cacheKey}_${classId}_${gradeSheetId}_records'; try { var response = await authenticatedDio.delete( @@ -343,11 +317,11 @@ class RemoteGradeSheetService extends GradeSheetService { @override Future updateStudentRecord( - String studentRecordId, - String gradeSheetId, - String grade, [ - String classId = 'single', - ]) async { + String studentRecordId, + String gradeSheetId, + String grade, [ + String classId = 'single', + ]) async { String recordsCacheKey = '${_cacheKey}_${classId}_${gradeSheetId}_records'; try { var response = await authenticatedDio.patch( @@ -376,4 +350,33 @@ class RemoteGradeSheetService extends GradeSheetService { } } } -} + + @override + Future> bulkImportRecords( + String gradeSheetId, + List> recordsList, [ + String classId = 'single', + ]) async { + try { + var response = await authenticatedDio.post( + '/classes/$classId/grade-sheets/$gradeSheetId/records/bulk', + data: recordsList, + ); + + if (response.statusCode == 201) { + return (response.data["ids"] as List) + .map((id) => id.toString()) + .toList(); + } + } on DioException catch (e) { + if (e.response?.statusCode == 404) { + throw NotGradeSheetFound(); + } else if (e.response?.statusCode == 400) { + rethrow; + } else { + rethrow; + } + } + return []; + } +} \ No newline at end of file diff --git a/mobile/lib/ui/app/organization/staff/students/bulk_import_students_screen.dart b/mobile/lib/ui/app/organization/staff/students/bulk_import_students_screen.dart index ec31dec0..85ecb133 100644 --- a/mobile/lib/ui/app/organization/staff/students/bulk_import_students_screen.dart +++ b/mobile/lib/ui/app/organization/staff/students/bulk_import_students_screen.dart @@ -37,7 +37,7 @@ class _BulkImportStudentsScreenState extends State { bool _loading = false; Future _pickCsvFile() async { - FilePickerResult? result = await FilePicker.platform.pickFiles( + FilePickerResult? result = await FilePicker.pickFiles( allowMultiple: false, type: FileType.custom, allowedExtensions: ['csv', 'xls', 'xlsx'], @@ -55,10 +55,8 @@ class _BulkImportStudentsScreenState extends State { if (file.lengthSync() > 0) { if (extension == '.csv') { Stream> input = File(filePath!).openRead(); - fields = await input - .transform(utf8.decoder) - .transform(const CsvToListConverter()) - .toList(); + var csvString = await input.transform(utf8.decoder).join(); + fields = csv.decode(csvString); } else if (extension == '.xls' || extension == '.xlsx') { var bytes = file.readAsBytesSync(); var excel = Excel.decodeBytes(bytes); diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/bulk_add_grade_records_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/bulk_add_grade_records_screen.dart index 4c57fca1..b8847491 100644 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/bulk_add_grade_records_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/bulk_add_grade_records_screen.dart @@ -41,7 +41,7 @@ class _BulkImportGradesScreenState extends State { bool _loading = false; Future _pickCsvFile() async { - FilePickerResult? result = await FilePicker.platform.pickFiles( + FilePickerResult? result = await FilePicker.pickFiles( allowMultiple: false, type: FileType.custom, allowedExtensions: ['csv', 'xls', 'xlsx'], @@ -59,10 +59,8 @@ class _BulkImportGradesScreenState extends State { if (file.lengthSync() > 0) { if (extension == '.csv') { Stream> input = File(filePath!).openRead(); - fields = await input - .transform(utf8.decoder) - .transform(const CsvToListConverter()) - .toList(); + var csvString = await input.transform(utf8.decoder).join(); + fields = csv.decode(csvString); } else if (extension == '.xls' || extension == '.xlsx') { var bytes = file.readAsBytesSync(); var excel = Excel.decodeBytes(bytes); diff --git a/mobile/lib/ui/app/single-class/staff/students/bulk_import_students_screen.dart b/mobile/lib/ui/app/single-class/staff/students/bulk_import_students_screen.dart index dcb7ad87..21b96a8f 100644 --- a/mobile/lib/ui/app/single-class/staff/students/bulk_import_students_screen.dart +++ b/mobile/lib/ui/app/single-class/staff/students/bulk_import_students_screen.dart @@ -37,7 +37,7 @@ class _BulkImportStudentsScreenState extends State { bool _loading = false; Future _pickCsvFile() async { - FilePickerResult? result = await FilePicker.platform.pickFiles( + FilePickerResult? result = await FilePicker.pickFiles( allowMultiple: false, type: FileType.custom, allowedExtensions: ['csv', 'xls', 'xlsx'], @@ -55,10 +55,8 @@ class _BulkImportStudentsScreenState extends State { if (file.lengthSync() > 0) { if (extension == '.csv') { Stream> input = File(filePath!).openRead(); - fields = await input - .transform(utf8.decoder) - .transform(const CsvToListConverter()) - .toList(); + var csvString = await input.transform(utf8.decoder).join(); + fields = csv.decode(csvString); } else if (extension == '.xls' || extension == '.xlsx') { var bytes = file.readAsBytesSync(); var excel = Excel.decodeBytes(bytes); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index e1b244a2..fa25e740 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -90,7 +90,7 @@ packages: source: hosted version: "4.1.1" build_runner: - dependency: "direct dev" + dependency: "direct main" description: name: build_runner sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"