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/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/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/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..358c61be 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'; @@ -19,16 +21,18 @@ 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'; 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'; @@ -41,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'; @@ -64,6 +69,8 @@ void main() async { var userCubit = UserCubit(); GetIt.I.registerSingleton(HiveTokenStorageService()); + GetIt.I.registerSingleton(RemoteConnectivityService()); + GetIt.I.registerSingleton(LocalCacheService()); GetIt.I.registerSingleton(RemoteStudentService()); GetIt.I.registerSingleton(RemoteAuthService()); GetIt.I.registerSingleton(RemoteTenantService()); @@ -76,10 +83,9 @@ 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()); await GetIt.I().init(); @@ -122,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/model/announcement/announcement.dart b/mobile/lib/model/announcement/announcement.dart index 4f8db424..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,17 @@ class Announcement { content: json['content'] ?? '', authorName: json['author'] ?? 'Unknown Author', createdAt: json['created_at'] ?? '', + isPending: json['isPending'] ?? false, ); } + + Map toJson() { + return { + 'id': id, + '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 ad975c56..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,20 @@ class Assignment { maxGrade: json['max_grade'] ?? 0, dueDate: json['due_date'] ?? '', submitted: json['submitted'] ?? false, + isPending: json['isPending'] ?? false, ); } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'author': author, + '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 3ed74b43..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,17 @@ class GradeSheet { name: json['name'], visible: json['visible'], grade: json['grade'], + isPending: json['isPending'] ?? false, ); } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'visible': visible, + 'grade': grade, + 'isPending': isPending, + }; + } } 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/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/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..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,23 @@ class Quiz { questions: (json['questions'] as List?) ?.map((e) => Question.fromJson(e)) .toList(), + isPending: json['isPending'] ?? false, ); } + + 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(), + 'isPending': isPending, + }; + } } diff --git a/mobile/lib/model/staff/staff.dart b/mobile/lib/model/staff/staff.dart index 3458da7f..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,10 +25,23 @@ 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, ); } + Map toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'permissions': permissions.map((p) => p.name).toList(), + 'created_at': createdAt, + 'isPending': isPending, + }; + } + + 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..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,10 +18,21 @@ class Student { id: json['id'], name: json['name'], email: json['email'], - createdAt: json['created_at'], + createdAt: json['created_at'] ?? '', + isPending: json['isPending'] ?? false, ); } + Map toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'created_at': createdAt, + 'isPending': isPending, + }; + } + 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..939be9f2 100644 --- a/mobile/lib/services/remote_announcement_service.dart +++ b/mobile/lib/services/remote_announcement_service.dart @@ -1,18 +1,38 @@ +import 'package:get_it/get_it.dart'; import 'package:yaspo_mobile/abstract/announcement_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/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 LocalCacheBaseService _cache = GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); + final String _cacheKey = 'announcements'; + @override 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(); + String cacheKey = '${_cacheKey}_$classId'; + try { + var response = await authenticatedDio.get( + "/classes/$classId/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(); + } } return []; } @@ -23,20 +43,58 @@ class RemoteAnnouncementService extends AnnouncementService { String content, [ String classId = 'single', ]) async { + String cacheKey = '${_cacheKey}_$classId'; + + if (!await _connectivity.hasInternet()) { + 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; - return Announcement( + var newAnnouncement = Announcement( id: data['id'], content: content, authorName: name, createdAt: DateTime.now().toIso8601String(), ); + + 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'); } @@ -47,11 +105,43 @@ 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) { + if (response.statusCode == 204) { + 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..01793531 100644 --- a/mobile/lib/services/remote_assignment_service.dart +++ b/mobile/lib/services/remote_assignment_service.dart @@ -1,17 +1,39 @@ 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/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/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 LocalCacheBaseService _cache = GetIt.I(); + final ConnectivityBaseService _connectivity = 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(); + String cacheKey = '${_cacheKey}_$classId'; + try { + var response = await authenticatedDio.get( + "/classes/$classId/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 []; } @@ -25,6 +47,44 @@ class RemoteAssignmentService extends AssignmentService { String author, [ String classId = 'single', ]) async { + String cacheKey = '${_cacheKey}_$classId'; + + if (!await _connectivity.hasInternet()) { + 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: { @@ -38,7 +98,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 +106,13 @@ class RemoteAssignmentService extends AssignmentService { dueDate: dueDate, author: author, ); + + 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 +123,43 @@ class RemoteAssignmentService extends AssignmentService { String assignmentId, [ String classId = 'single', ]) async { - var response = await authenticatedDio.delete("/classes/$classId/assignments/$assignmentId"); + 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'] == 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", + ); - if (response.statusCode != 204) { + if (response.statusCode == 204) { + 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 +173,7 @@ class RemoteAssignmentService extends AssignmentService { String dueDate, [ String classId = 'single', ]) async { + String cacheKey = '${_cacheKey}_$classId'; var data = { "title": title, "description": description, @@ -79,12 +181,60 @@ 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) return; + 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'] == 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,9 +252,11 @@ class RemoteAssignmentService extends AssignmentService { void Function(int, int) onProgress, [ String classId = 'single', ]) async { + String cacheKey = '${_cacheKey}_$classId'; + var response = await authenticatedDio.put( - "/classes/$classId/assignments/$assignmentId/submissions", data: {"size": size.toString(), "type": mimeType}, + "/classes/$classId/assignments/$assignmentId/submissions", ); if (response.statusCode != 200 && response.statusCode != 201) { @@ -129,6 +281,20 @@ class RemoteAssignmentService extends AssignmentService { "/classes/$classId/assignments/$assignmentId/submissions/confirm", ); + if (confirmResponse.statusCode == 200) { + 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; + } if (confirmResponse.statusCode != 200) { throw Exception('Failed to confirm submission'); } 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_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..4d1976ae 100644 --- a/mobile/lib/services/remote_grade_sheet_service.dart +++ b/mobile/lib/services/remote_grade_sheet_service.dart @@ -1,37 +1,99 @@ +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'; import 'package:yaspo_mobile/exceptions/grade_sheet_record_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 LocalCacheBaseService _cache = GetIt.I(); + final ConnectivityBaseService _connectivity = GetIt.I(); + final String _cacheKey = 'grade_sheets'; + @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(); + String cacheKey = '${_cacheKey}_$classId'; + try { + var response = await authenticatedDio.get( + "/classes/$classId/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 []; } @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}; + if (!await _connectivity.hasInternet()) { + String tempId = 'pending_${DateTime.now().millisecondsSinceEpoch}'; + var optimistic = GradeSheet( + id: tempId, + name: name, + visible: visible, + 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/grade-sheets", + data: data, + entityType: 'grade_sheet', + classId: classId, + ), + ); + + return optimistic; + } + var response = await authenticatedDio.put( "/classes/$classId/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, + ); + + 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'); } @@ -39,14 +101,45 @@ 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()) { + 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 { var response = await authenticatedDio.delete( "/classes/$classId/grade-sheets/$gradeSheetId", ); if (response.statusCode == 204) { + 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) { @@ -60,16 +153,58 @@ 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()) { + 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 { var response = await authenticatedDio.post( "/classes/$classId/grade-sheets/$gradeSheetId/visible", data: {"visible": visible}, ); 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'] == 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) { @@ -81,26 +216,31 @@ class RemoteGradeSheetService extends GradeSheetService { } } - // grade Sheet Records @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( '/classes/$classId/grade-sheets/$gradeSheetId', ); if (response.statusCode == 200) { - return (response.data as List) - .map((e) => GradeSheetRecord.fromJson(e)) - .toList(); + List data = response.data; + await _cache.cacheData(recordsCacheKey, data); + return data.map((e) => GradeSheetRecord.fromJson(e)).toList(); } } on DioException catch (e) { if (e.response?.statusCode == 404) { throw NotGradeSheetFound(); - } else { - rethrow; + } + } catch (_) { + var cachedData = await _cache.getCachedData(recordsCacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => GradeSheetRecord.fromJson(Map.from(e))) + .toList(); } } return []; @@ -108,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( @@ -120,16 +260,26 @@ class RemoteGradeSheetService extends GradeSheetService { data: data, ); if (response.statusCode == 201) { - return GradeSheetRecord( + var newRecord = GradeSheetRecord( id: response.data["id"], studentId: studentId, grade: grade, ); + + String recordsCacheKey = + '${_cacheKey}_${classId}_${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) { throw NotGradeSheetFound(); } else { + log('Status code: ${e.response?.statusCode}'); rethrow; } } @@ -137,45 +287,59 @@ class RemoteGradeSheetService extends GradeSheetService { } @override - Future> bulkImportRecords( - String gradeSheetId, - List> recordsList, [ - String classId = 'single', - ]) async { + Future deleteStudentRecord( + String gradeSheetId, + String studentRecordId, [ + String classId = 'single', + ]) async { + String recordsCacheKey = '${_cacheKey}_${classId}_${gradeSheetId}_records'; try { - var response = await authenticatedDio.post( - '/classes/$classId/grade-sheets/$gradeSheetId/records/bulk', - data: recordsList, + var response = await authenticatedDio.delete( + '/classes/$classId/grade-sheets/$gradeSheetId/records/$studentRecordId', ); - - if (response.statusCode == 201) { - return (response.data["ids"] as List) - .map((id) => id.toString()) - .toList(); + if (response.statusCode == 204) { + 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) { 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 { + Future updateStudentRecord( + String studentRecordId, + String gradeSheetId, + String grade, [ + String classId = 'single', + ]) async { + String recordsCacheKey = '${_cacheKey}_${classId}_${gradeSheetId}_records'; try { - var response = await authenticatedDio.delete( + var response = await authenticatedDio.patch( '/classes/$classId/grade-sheets/$gradeSheetId/records/$studentRecordId', + data: {"grade": grade}, ); - if (response.statusCode == 204) { + if (response.statusCode == 200) { + 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) { @@ -188,26 +352,31 @@ class RemoteGradeSheetService extends GradeSheetService { } @override - Future updateStudentRecord( - String studentRecordId, - String gradeSheetId, - String grade, [ - String classId = 'single', - ]) async { + Future> bulkImportRecords( + String gradeSheetId, + List> recordsList, [ + String classId = 'single', + ]) async { try { - var response = await authenticatedDio.patch( - '/classes/$classId/grade-sheets/$gradeSheetId/records/$studentRecordId', - data: {"grade": grade}, + var response = await authenticatedDio.post( + '/classes/$classId/grade-sheets/$gradeSheetId/records/bulk', + data: recordsList, ); - if (response.statusCode == 200) { - return; + + 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/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 07ea923e..8b77ca03 100644 --- a/mobile/lib/services/remote_quiz_service.dart +++ b/mobile/lib/services/remote_quiz_service.dart @@ -1,3 +1,4 @@ +import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; import 'package:yaspo_mobile/abstract/quiz_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; @@ -5,33 +6,55 @@ 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/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 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) { - 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 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, 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); + if (cachedData != null) { + return Quiz.fromJson(Map.from(cachedData)); + } + if (e is DioException && e.response?.statusCode == 404) { throw QuizNotFound(); } rethrow; @@ -43,19 +66,25 @@ class RemoteQuizService implements QuizService { String quizId, [ String classId = 'single', ]) async { + var cacheKey = '${_cacheKey}_${classId}_${quizId}_submissions'; try { var response = await authenticatedDio.get( '/classes/$classId/quizzes/$quizId/submissions', ); if (response.statusCode == 200) { - return (response.data as List) - .map((e) => Submission.fromJson(e)) + var data = response.data as List; + await _cache.cacheData(cacheKey, data); + return data.map((e) => Submission.fromJson(e)).toList(); + } + } catch (_) { + var cachedData = await _cache.getCachedData(cacheKey); + if (cachedData != null) { + return (cachedData as List) + .map((e) => Submission.fromJson(Map.from(e))) .toList(); } - return []; - } catch (e) { - rethrow; } + return []; } @override @@ -64,15 +93,21 @@ class RemoteQuizService implements QuizService { String submissionId, [ String classId = 'single', ]) async { + var cacheKey = '${_cacheKey}_${classId}_${quizId}_submission_$submissionId'; try { 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 +122,7 @@ class RemoteQuizService implements QuizService { List questions, [ String classId = 'single', ]) async { + String cacheKey = '${_cacheKey}_$classId'; var data = { 'title': title, 'is_published': isPublished, @@ -96,13 +132,68 @@ 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', 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); + + 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 +212,7 @@ class RemoteQuizService implements QuizService { List questions, [ String classId = 'single', ]) async { + String cacheKey = '${_cacheKey}_$classId'; var data = { 'title': title, 'is_published': isPublished, @@ -130,14 +222,69 @@ 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); + 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(); @@ -151,13 +298,45 @@ class RemoteQuizService implements QuizService { @override Future deleteQuiz(String id, [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'] == 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) { - throw Exception('Failed to delete quiz'); + if (response.statusCode == 200) { + 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 +347,23 @@ class RemoteQuizService implements QuizService { @override Future startQuiz(String quizId, [String classId = 'single']) async { + 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); + 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'); @@ -193,12 +384,24 @@ class RemoteQuizService implements QuizService { 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); + 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 +415,21 @@ class RemoteQuizService implements QuizService { String quizId, [ String classId = 'single', ]) async { + var cacheKey = '${_cacheKey}_${classId}_${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..2978f044 100644 --- a/mobile/lib/services/remote_staff_service.dart +++ b/mobile/lib/services/remote_staff_service.dart @@ -1,3 +1,4 @@ +import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; import 'package:yaspo_mobile/abstract/staff_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; @@ -6,14 +7,28 @@ 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 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,21 +56,30 @@ class RemoteStaffService extends StaffService { data['password'] = password; data['send_instructions'] = sendInstructions; } + 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(), ); + + 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) { throw EmailAlreadyExist(); } + rethrow; } catch (e) { rethrow; } @@ -65,7 +89,14 @@ class RemoteStaffService extends StaffService { @override Future deleteStaff(String id) async { var response = await authenticatedDio.delete('/staff/$id'); + if (response.statusCode == 200) { + 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'); } @@ -89,12 +120,28 @@ class RemoteStaffService extends StaffService { var response = await authenticatedDio.patch('/staff/$id', data: data); 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'] == 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) { if (e.response?.statusCode == 409) { throw EmailAlreadyExist(); } + rethrow; } catch (e) { rethrow; } @@ -116,9 +163,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 +173,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(); } @@ -142,10 +193,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 []; } @@ -158,6 +220,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 e0d0d178..5b9f9d53 100644 --- a/mobile/lib/services/remote_student_service.dart +++ b/mobile/lib/services/remote_student_service.dart @@ -1,3 +1,4 @@ +import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; import 'package:yaspo_mobile/abstract/student_service.dart'; import 'package:yaspo_mobile/core/authenticated_dio.dart'; @@ -5,16 +6,28 @@ 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 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 []; } @@ -36,20 +49,29 @@ class RemoteStudentService extends StudentService { data['password'] = password; data["send_instructions"] = sendInstructions; } + 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(), ); + + 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) { throw EmailAlreadyExist(); } + rethrow; } catch (e) { rethrow; } @@ -98,6 +120,12 @@ class RemoteStudentService extends StudentService { var response = await authenticatedDio.delete('/students/$id'); if (response.statusCode == 200) { + 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'); } @@ -106,15 +134,29 @@ class RemoteStudentService extends StudentService { @override Future updateStudentInfo(String id, String name, String email) async { Map data = {'name': name, 'email': email}; + try { var response = await authenticatedDio.patch('/students/$id', data: data); 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'] == 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) { if (e.response?.statusCode == 409) { throw EmailAlreadyExist(); } + rethrow; } catch (e) { rethrow; } @@ -151,12 +193,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(); } @@ -168,15 +216,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 []; } @@ -192,6 +246,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..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,6 +53,10 @@ class StaffWidget extends StatelessWidget { ), ), ), + Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), ], ), ), 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/organization/staff/students/student_widget.dart b/mobile/lib/ui/app/organization/staff/students/student_widget.dart index b6be0300..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,6 +53,10 @@ class StudentWidget extends StatelessWidget { ), ), ), + 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 5a0ec738..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 @@ -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,17 +72,20 @@ 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: (_) {}, ); } } 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 4fce087a..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 @@ -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,24 +164,27 @@ 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: (_) {}, ); } } finally { - setState(() => isSubmitting = false); + if (mounted) setState(() => isSubmitting = false); } } @@ -258,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/assignments/update_assignment_screen.dart b/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart index b194e063..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 @@ -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,24 +191,27 @@ 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: (_) {}, ); } } 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/add_grade_sheet_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart index a4eccc86..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 @@ -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: (_) {}, ); } @@ -141,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/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/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..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 @@ -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'; @@ -93,18 +94,21 @@ 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: (_) {}, ); } finally { 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/profile/reset_password_screen.dart b/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart index b8f81669..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 @@ -5,6 +5,7 @@ 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'; @@ -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,15 +92,19 @@ 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: (_) {}, - ); + 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..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,6 +17,7 @@ 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/exceptions/network_exception.dart'; import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; import 'package:yaspo_mobile/values/spaces.dart'; @@ -219,7 +220,7 @@ class _UpdateInfoScreenState extends State { text: "E-mail is already used", onComplete: (_) {}, ); - } catch (_) { + } catch (e) { await GetIt.I().trackEvent( 'update_info', parameters: { @@ -229,14 +230,19 @@ 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: (_) {}, - ); + + 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/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/add_staff_screen.dart b/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart index a8deaf24..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 @@ -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'; @@ -116,18 +117,21 @@ class _AddStaffScreenState extends State { onComplete: (_) {}, ); } - } catch (_) { + } catch (e) { await GetIt.I().trackEvent( 'add_staff', 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: (_) {}, ); } @@ -193,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, @@ -237,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, ), @@ -275,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/staff/staff/staff_widget.dart b/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart index 426e15bd..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,6 +53,10 @@ class StaffWidget extends StatelessWidget { ), ), ), + 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/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/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/lib/ui/app/single-class/staff/students/student_widget.dart b/mobile/lib/ui/app/single-class/staff/students/student_widget.dart index 562477f8..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,6 +53,10 @@ class StudentWidget extends StatelessWidget { ), ), ), + Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), ], ), ), 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/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/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.lock b/mobile/pubspec.lock index b62cb00f..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" @@ -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 206f88c2..a70d6d65 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -34,6 +34,8 @@ dependencies: credential_manager: ^2.0.8 excel: ^4.0.6 path: ^1.9.1 + build_runner: ^2.15.0 + connectivity_plus: ^7.1.1 dev_dependencies: @@ -41,7 +43,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 @@ -58,7 +59,5 @@ flutter: - assets/images/organization.png - assets/images/single-class.png - dependency_overrides: - path_provider_android: 2.2.23 - + path_provider_android: ^2.3.1 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/announcement/add_announcement_page_test.dart b/mobile/test/ui/app/single-class/staff/announcement/add_announcement_page_test.dart similarity index 91% 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..dc5cb850 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,11 @@ 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/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; @@ -73,6 +76,7 @@ void main() { GetIt.I.registerSingleton(mockAnnouncementService); GetIt.I.registerSingleton(mockAnalyticsService); + GetIt.I.registerSingleton(MockOfflineSyncService()); await setupWidget(tester); await tester.pumpAndSettle(); @@ -107,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/announcement/announcement_page_test.dart b/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart similarity index 93% 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..edff728a 100644 --- a/mobile/test/ui/app/announcement/announcement_page_test.dart +++ b/mobile/test/ui/app/single-class/staff/announcement/announcement_page_test.dart @@ -15,8 +15,10 @@ 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'; +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);