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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions mobile/lib/abstract/connectivity_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
abstract class ConnectivityBaseService {
Future<bool> hasInternet();
}
5 changes: 5 additions & 0 deletions mobile/lib/abstract/local_cache_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
abstract class LocalCacheBaseService {
Future<void> cacheData(String boxName, dynamic data);
Future<dynamic> getCachedData(String boxName);
Future<void> clearCache(String boxName);
}
9 changes: 9 additions & 0 deletions mobile/lib/abstract/offline_sync_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import 'package:yaspo_mobile/model/offline_sync/offline_sync_request.dart';

abstract class OfflineSyncService {
Stream<void> get onSyncComplete;

Future<void> enqueueRequest(OfflineSyncRequest request);

void onInternetConnected();
}
119 changes: 107 additions & 12 deletions mobile/lib/core/authenticated_dio.dart
Original file line number Diff line number Diff line change
@@ -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'}),
Expand All @@ -12,6 +14,26 @@ final authenticatedDio = Dio(
bool _isRefreshing = false;
final List<Completer<String>> _failedQueue = [];

class _OfflineRequest {
final Completer<Response> 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<String> _waitForTokenRefresh() {
var completer = Completer<String>();
_failedQueue.add(completer);
Expand All @@ -29,7 +51,53 @@ void _processQueue(Object? error, String? token) {
_failedQueue.clear();
}

Future<Response> _waitForInternetAndRetry(RequestOptions requestOptions) {
var completer = Completer<Response>();
_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<OfflineSyncService>().onInternetConnected();
if (_offlineQueue.isNotEmpty) {
_processOfflineQueue();
}
}
});
}

void setupAuthenticatedDio() {
_initConnectivityListener();

authenticatedDio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
Expand All @@ -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;
Expand All @@ -68,18 +147,34 @@ void setupAuthenticatedDio() {
var newAccessToken = response.data['access_token'];
var newRefreshToken = response.data['refresh_token'];

await GetIt.I<TokenStorageService>().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<TokenStorageService>().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);
Expand Down
8 changes: 2 additions & 6 deletions mobile/lib/cubit/quiz/quiz_session_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,13 @@ class QuizSessionCubit extends Cubit<QuizSessionState> {

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));
}
}

Expand Down
7 changes: 7 additions & 0 deletions mobile/lib/exceptions/network_exception.dart
Original file line number Diff line number Diff line change
@@ -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;
}
62 changes: 35 additions & 27 deletions mobile/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand All @@ -64,6 +69,8 @@ void main() async {
var userCubit = UserCubit();

GetIt.I.registerSingleton<TokenStorageService>(HiveTokenStorageService());
GetIt.I.registerSingleton<ConnectivityBaseService>(RemoteConnectivityService());
GetIt.I.registerSingleton<LocalCacheBaseService>(LocalCacheService());
GetIt.I.registerSingleton<StudentService>(RemoteStudentService());
GetIt.I.registerSingleton<AuthService>(RemoteAuthService());
GetIt.I.registerSingleton<TenantService>(RemoteTenantService());
Expand All @@ -76,10 +83,9 @@ void main() async {
GetIt.I.registerSingleton<UserService>(RemoteUserService());
GetIt.I.registerSingleton<QuizService>(RemoteQuizService());
GetIt.I.registerSingleton<AnalyticsService>(RemoteAnalyticsService());
GetIt.I.registerSingleton<CredentialManagerService>(
LocalCredentialManagerService(),
);
GetIt.I.registerSingleton<CredentialManagerService>(LocalCredentialManagerService());
GetIt.I.registerSingleton<ClassService>(RemoteClassService());
GetIt.I.registerSingleton<OfflineSyncService>(RemoteOfflineSyncService());

await GetIt.I<CredentialManagerService>().init();

Expand Down Expand Up @@ -122,37 +128,39 @@ class _MyAppState extends State<MyApp> {
}

Future<void> refreshUserToken() async {
var refreshToken = await GetIt.I<TokenStorageService>().getRefreshToken();
if (refreshToken == null) {
setState(() {
isLoading = false;
});
var tokenStorage = GetIt.I<TokenStorageService>();
var authService = GetIt.I<AuthService>();

var refreshToken = await tokenStorage.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
if (mounted) {
setState(() {
isLoading = false;
});
}
return;
}

try {
var response = await GetIt.I<AuthService>().refresh(refreshToken);
var response = await authService.refresh(refreshToken);

await GetIt.I<TokenStorageService>().setTokens(
response.accessToken,
response.refreshToken,
);
if (response.accessToken.isNotEmpty) {
String newRefreshToken = response.refreshToken.isNotEmpty ? response.refreshToken : refreshToken;

if (mounted) {
context.read<UserCubit>().setUserModel(
TokenUtil.decodeToken(response.accessToken),
);
}
} on InvalidCredentialException {
await GetIt.I<TokenStorageService>().clearTokens();
await tokenStorage.setTokens(response.accessToken, newRefreshToken);

if (mounted) context.read<UserCubit>().setUserModel(null);
} catch (_) {
var newUser = TokenUtil.decodeToken(response.accessToken);
if (mounted) {
context.read<UserCubit>().setUserModel(newUser);
}
}
} catch (_) {
} finally {
setState(() {
isLoading = false;
});
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}

Expand Down
13 changes: 13 additions & 0 deletions mobile/lib/model/announcement/announcement.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> json) {
Expand All @@ -17,6 +19,17 @@ class Announcement {
content: json['content'] ?? '',
authorName: json['author'] ?? 'Unknown Author',
createdAt: json['created_at'] ?? '',
isPending: json['isPending'] ?? false,
);
}

Map<String, dynamic> toJson() {
return {
'id': id,
'content': content,
'author': authorName,
'created_at': createdAt,
'isPending': isPending,
};
}
}
Loading
Loading