diff --git a/lib/exceptions/exceptions.dart b/lib/exceptions/exceptions.dart index af305350..5f5fc00b 100644 --- a/lib/exceptions/exceptions.dart +++ b/lib/exceptions/exceptions.dart @@ -1,13 +1,63 @@ +import 'dart:async'; + import 'package:dart_mappable/dart_mappable.dart'; +import 'package:fpdart/fpdart.dart'; part 'exceptions.mapper.dart'; +/// App wide wrapped exception +typedef SyncEither = Either; + +/// App wide wrapped exception for future. +typedef AsyncEither = TaskEither; + +/// App wide wrapped exception with void as value. +typedef SyncVoidEither = Either; + +/// App wide wrapped exception for future with void as value. +typedef AsyncVoidEither = TaskEither; + +/// Convenient `right(null)` +Either rightVoid() => Right(null); + +/// Functional programming operators on [AsyncEither] with generic type [T]. +extension FPFutureExt on AsyncEither { + /// Await and handle result. + Future handle( + FutureOr Function(AppException e) onLeft, + FutureOr Function(T v) onRight, + ) async { + switch (await run()) { + case Left(:final value): + onLeft(value); + case Right(:final value): + onRight(value); + } + } +} + +/// Base class for all exceptions. +@MappableClass() +sealed class AppException with AppExceptionMappable implements Exception { + AppException({ + this.message, + }) : stackTrace = StackTrace.current; + + /// Message to print + final String? message; + + /// Collected stack trace. + late final StackTrace stackTrace; +} + /// Exception represents an error occurred in http request. /// /// Usually the response status code is not 200. -class HttpRequestFailedException implements Exception { +@MappableClass() +final class HttpRequestFailedException extends AppException + with HttpRequestFailedExceptionMappable { /// Constructor. - const HttpRequestFailedException(this.statusCode); + HttpRequestFailedException(this.statusCode); /// Returned status code. final int? statusCode; @@ -21,25 +71,75 @@ class HttpRequestFailedException implements Exception { /// Till now we manually set the status code to 999 to indicate this error, but /// it should be refactored to a more proper way. @MappableClass() -class HttpHandshakeFailedException - with HttpHandshakeFailedExceptionMappable - implements Exception { +final class HttpHandshakeFailedException extends AppException + with HttpHandshakeFailedExceptionMappable { /// Constructor. - const HttpHandshakeFailedException(this.message); - - /// Error message. - final String message; + HttpHandshakeFailedException(String message) : super(message: message); } -/// Invalid setting's key (aka name) when accessing settings values: -/// setting values or getting values. +/// The form hash used in login progress is not found. @MappableClass() -class InvalidSettingsKeyException - with InvalidSettingsKeyExceptionMappable - implements Exception { - /// Constructor. - const InvalidSettingsKeyException(this.message); +final class LoginFormHashNotFoundException extends AppException + with LoginFormHashNotFoundExceptionMappable {} + +/// Found form hash, but it's not in the expect format. +@MappableClass() +final class LoginInvalidFormHashException extends AppException + with LoginInvalidFormHashExceptionMappable {} + +/// The login result message of login progress is not found. +/// +/// Indicating that we do not know whether we logged in successful or not. +@MappableClass() +final class LoginMessageNotFoundException extends AppException + with LoginMessageNotFoundExceptionMappable {} + +/// The captcha user texted is incorrect. +@MappableClass() +final class LoginIncorrectCaptchaException extends AppException + with LoginIncorrectCaptchaExceptionMappable {} + +/// Incorrect password or account. +@MappableClass() +final class LoginInvalidCredentialException extends AppException + with LoginInvalidCredentialExceptionMappable {} + +/// Security question or its answer is incorrect. +@MappableClass() +final class LoginIncorrectSecurityQuestionException extends AppException + with LoginIncorrectSecurityQuestionExceptionMappable {} + +/// Reached the limit of login attempt. +/// +/// Maybe locked in 20 minutes. +@MappableClass() +final class LoginAttemptLimitException extends AppException + with LoginAttemptLimitExceptionMappable {} - /// Error message. - final String message; +/// User info not found when try to login after login seems success. +/// +/// Now we should update the logged user info but this exception means we can +/// not found the logged user info. +@MappableClass() +final class LoginUserInfoNotFoundException extends AppException + with LoginUserInfoNotFoundExceptionMappable {} + +/// Some other exception that not recognized. +@MappableClass() +final class LoginOtherErrorException extends AppException + with LoginOtherErrorExceptionMappable { + /// Constructor. + LoginOtherErrorException(String message) : super(message: message); } + +/// The form hash used to logout is not found. +@MappableClass() +final class LogoutFormHashNotFoundException extends AppException + with LogoutFormHashNotFoundExceptionMappable {} + +/// Failed to logout. +/// +/// Nearly impossible to happen. +@MappableClass() +final class LogoutFailedException extends AppException + with LogoutFailedExceptionMappable {} diff --git a/lib/features/authentication/bloc/authentication_bloc.dart b/lib/features/authentication/bloc/authentication_bloc.dart index 7aa98a78..3fcf7930 100644 --- a/lib/features/authentication/bloc/authentication_bloc.dart +++ b/lib/features/authentication/bloc/authentication_bloc.dart @@ -2,7 +2,6 @@ import 'package:dart_mappable/dart_mappable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; -import 'package:tsdm_client/features/authentication/repository/exceptions/exceptions.dart'; import 'package:tsdm_client/features/authentication/repository/models/models.dart'; import 'package:tsdm_client/utils/logger.dart'; @@ -11,7 +10,7 @@ part 'authentication_event.dart'; part 'authentication_state.dart'; /// Emitter -typedef AuthenticationEmitter = Emitter; +typedef _Emitter = Emitter; /// Bloc the authentication, including login and logout. /// @@ -23,60 +22,45 @@ class AuthenticationBloc extends Bloc required AuthenticationRepository authenticationRepository, }) : _authenticationRepository = authenticationRepository, super(const AuthenticationState()) { - on( - _onAuthenticationFetchLoginHashRequested, + on( + (event, emitter) => switch (event) { + AuthenticationFetchLoginHashRequested() => + _onFetchLoginHashRequested(emitter), + AuthenticationLoginRequested(:final userCredential) => + _onLoginRequested(emitter, userCredential), + }, ); - on(_onAuthenticationLoginRequested); } final AuthenticationRepository _authenticationRepository; - Future _onAuthenticationFetchLoginHashRequested( - AuthenticationFetchLoginHashRequested event, - AuthenticationEmitter emit, - ) async { + Future _onFetchLoginHashRequested(_Emitter emit) async { emit(state.copyWith(status: AuthenticationStatus.fetchingHash)); - try { - final loginHash = await _authenticationRepository.fetchHash(); - emit( + await _authenticationRepository.fetchHash().match( + (e) { + handle(e); + emit(state.copyWith(status: AuthenticationStatus.failure)); + }, + (v) => emit( state.copyWith( status: AuthenticationStatus.gotHash, - loginHash: loginHash, - ), - ); - } on HttpRequestFailedException catch (e) { - debug('failed to fetch login hash: $e'); - emit(state.copyWith(status: AuthenticationStatus.failure)); - } on LoginException catch (e) { - debug('failed to fetch login hash: $e'); - emit( - state.copyWith( - status: AuthenticationStatus.failure, - loginException: e, + loginHash: v, ), - ); - } + ), + ).run(); } - Future _onAuthenticationLoginRequested( - AuthenticationLoginRequested event, - AuthenticationEmitter emit, + Future _onLoginRequested( + _Emitter emit, + UserCredential userCredential, ) async { emit(state.copyWith(status: AuthenticationStatus.loggingIn)); - try { - await _authenticationRepository.loginWithPassword(event.userCredential); - emit(state.copyWith(status: AuthenticationStatus.success)); - } on HttpRequestFailedException catch (e) { - debug('failed to login: $e'); - emit(state.copyWith(status: AuthenticationStatus.failure)); - } on LoginException catch (e) { - debug('failed to login: $e'); - emit( - state.copyWith( - status: AuthenticationStatus.failure, - loginException: e, - ), - ); - } + await _authenticationRepository.loginWithPassword(userCredential).match( + (e) { + handle(e); + emit(state.copyWith(status: AuthenticationStatus.failure)); + }, + (_) => emit(state.copyWith(status: AuthenticationStatus.success)), + ).run(); } } diff --git a/lib/features/authentication/bloc/authentication_state.dart b/lib/features/authentication/bloc/authentication_state.dart index de5380c8..4017b73d 100644 --- a/lib/features/authentication/bloc/authentication_state.dart +++ b/lib/features/authentication/bloc/authentication_state.dart @@ -42,5 +42,5 @@ final class AuthenticationState with AuthenticationStateMappable { final LoginHash? loginHash; /// Exception happened in login. - final LoginException? loginException; + final AppException? loginException; } diff --git a/lib/features/authentication/repository/authentication_repository.dart b/lib/features/authentication/repository/authentication_repository.dart index 41b55570..7191c39f 100644 --- a/lib/features/authentication/repository/authentication_repository.dart +++ b/lib/features/authentication/repository/authentication_repository.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'dart:io' if (dart.libaray.js) 'package:web/web.dart'; +import 'package:fpdart/fpdart.dart'; import 'package:rxdart/rxdart.dart'; import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/extensions/string.dart'; import 'package:tsdm_client/extensions/universal_html.dart'; -import 'package:tsdm_client/features/authentication/repository/exceptions/exceptions.dart'; import 'package:tsdm_client/features/authentication/repository/internal/login_result.dart'; import 'package:tsdm_client/features/authentication/repository/models/models.dart'; import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; @@ -79,43 +79,37 @@ class AuthenticationRepository with LoggerMixin { } /// Fetch login hash and form hash for logging in. - /// - /// # Exception - /// - /// * **HttpRequestFailedException** when http request failed. - /// * **LoginFormHashNotFoundException** when form hash not found. - /// * **LoginInvalidFormHashException** when form hash found but not in the - /// expected format. - Future fetchHash() async { - // TODO: Parse CDATA. - // 返回的data是xml: - // - // - // - //
- // - // 其中"main_message_"后面的是本次登录的loginHash,登录时需要加到url上 - final rawDataResp = await getIt.get().get(_fakeFormUrl); - if (rawDataResp.statusCode != HttpStatus.ok) { - throw HttpRequestFailedException(rawDataResp.statusCode); - } - final data = rawDataResp.data as String; - final match = _layerLoginRe.firstMatch(data); - final loginHash = match?.namedGroup('Hash'); - if (loginHash == null) { - throw LoginFormHashNotFoundException(); - } - - final formHashMatch = _formHashRe.firstMatch(data); - final formHash = formHashMatch?.namedGroup('FormHash'); - if (formHash == null) { - throw LoginInvalidFormHashException(); - } - - debug('get login hash $loginHash'); - return LoginHash(formHash: formHash, loginHash: loginHash); - } + AsyncEither fetchHash() => AsyncEither(() async { + // TODO: Parse CDATA. + // 返回的data是xml: + // + // + // + //
+ // + // 其中"main_message_"后面的是本次登录的loginHash,登录时需要加到url上 + final rawDataResp = + await getIt.get().get(_fakeFormUrl); + if (rawDataResp.statusCode != HttpStatus.ok) { + return left(HttpRequestFailedException(rawDataResp.statusCode)); + } + final data = rawDataResp.data as String; + final match = _layerLoginRe.firstMatch(data); + final loginHash = match?.namedGroup('Hash'); + if (loginHash == null) { + return left(LoginFormHashNotFoundException()); + } + + final formHashMatch = _formHashRe.firstMatch(data); + final formHash = formHashMatch?.namedGroup('FormHash'); + if (formHash == null) { + return left(LoginInvalidFormHashException()); + } + + debug('get login hash $loginHash'); + return right(LoginHash(formHash: formHash, loginHash: loginHash)); + }); /// Login with password and other parameters in [credential]. /// @@ -125,84 +119,79 @@ class AuthenticationRepository with LoggerMixin { /// /// * **[HttpRequestFailedException]** when http request failed. /// - /// # Sealed Exception - /// - /// * **[LoginException]** when login request was refused by the server side. - Future loginWithPassword(UserCredential credential) async { - final target = _buildLoginUrl(credential.formHash); - final userLoginInfo = switch (credential.loginField) { - LoginField.username => UserLoginInfo( - username: credential.loginFieldValue, - uid: null, - email: null, - ), - LoginField.uid => UserLoginInfo( - username: null, - uid: credential.loginFieldValue.parseToInt(), - email: null, - ), - LoginField.email => UserLoginInfo( - username: null, - uid: null, - email: credential.loginFieldValue, - ), - }; - - debug('login with user info: $userLoginInfo'); - // Here the userLoginInfo is incomplete: - // - // Only contains one login field: Username, uid or email. - final netClient = NetClientProvider.build( - userLoginInfo: userLoginInfo, - startLogin: true, - ); - final resp = await netClient.postForm(target, data: credential.toJson()); - if (resp.statusCode != HttpStatus.ok) { - throw HttpRequestFailedException(resp.statusCode); - } - final document = parseHtmlDocument(resp.data as String); - final messageNode = document.getElementById('messagetext'); - if (messageNode == null) { - error('failed to check login result: result node not found'); - throw LoginMessageNotFoundException(); - } - - final loginResult = LoginResult.fromLoginMessageNode(messageNode); - if (loginResult == LoginResult.success) { - // Get complete user info from page. - final fullInfoResp = await netClient.get(_passwordSettingsUrl); - if (fullInfoResp.statusCode != HttpStatus.ok) { - error('failed to fetch complete user info: ' - 'code=${fullInfoResp.statusCode}'); - throw LoginUserInfoNotFoundException(); - } - final fullInfoDoc = parseHtmlDocument(fullInfoResp.data as String); - final fullUserInfo = - _parseUserInfoFromDocument(fullInfoDoc, parseEmail: true); - if (fullUserInfo == null || !fullUserInfo.isComplete) { - error('failed to check login result: user info is null'); - throw LoginUserInfoNotFoundException(); - } - - // Mark login progress has ended. - await CookieData.endLogin(fullUserInfo); - - // Here we get complete user info. - await _saveLoggedUserInfo(fullUserInfo); - - debug('login finished'); - - _controller.add(AuthenticationStatus.authenticated); - - return; - } - - try { - _parseAndThrowLoginResult(loginResult); - } on LoginException { - rethrow; - } - } + AsyncVoidEither loginWithPassword(UserCredential credential) => + AsyncVoidEither(() async { + final target = _buildLoginUrl(credential.formHash); + final userLoginInfo = switch (credential.loginField) { + LoginField.username => UserLoginInfo( + username: credential.loginFieldValue, + uid: null, + email: null, + ), + LoginField.uid => UserLoginInfo( + username: null, + uid: credential.loginFieldValue.parseToInt(), + email: null, + ), + LoginField.email => UserLoginInfo( + username: null, + uid: null, + email: credential.loginFieldValue, + ), + }; + + debug('login with user info: $userLoginInfo'); + // Here the userLoginInfo is incomplete: + // + // Only contains one login field: Username, uid or email. + final netClient = NetClientProvider.build( + userLoginInfo: userLoginInfo, + startLogin: true, + ); + final resp = + await netClient.postForm(target, data: credential.toJson()); + if (resp.statusCode != HttpStatus.ok) { + return left(HttpRequestFailedException(resp.statusCode)); + } + final document = parseHtmlDocument(resp.data as String); + final messageNode = document.getElementById('messagetext'); + if (messageNode == null) { + error('failed to check login result: result node not found'); + return left(LoginMessageNotFoundException()); + } + + final loginResult = LoginResult.fromLoginMessageNode(messageNode); + if (loginResult == LoginResult.success) { + // Get complete user info from page. + final fullInfoResp = await netClient.get(_passwordSettingsUrl); + if (fullInfoResp.statusCode != HttpStatus.ok) { + error('failed to fetch complete user info: ' + 'code=${fullInfoResp.statusCode}'); + return left(LoginUserInfoNotFoundException()); + } + final fullInfoDoc = parseHtmlDocument(fullInfoResp.data as String); + final fullUserInfo = + _parseUserInfoFromDocument(fullInfoDoc, parseEmail: true); + if (fullUserInfo == null || !fullUserInfo.isComplete) { + error('failed to check login result: user info is null'); + return left(LoginUserInfoNotFoundException()); + } + + // Mark login progress has ended. + await CookieData.endLogin(fullUserInfo); + + // Here we get complete user info. + await _saveLoggedUserInfo(fullUserInfo); + + debug('login finished'); + + _controller.add(AuthenticationStatus.authenticated); + + return rightVoid(); + } + + return _mapLoginResult(loginResult); + }); /// Parse logged user info from html [document]. Future loginWithDocument(uh.Document document) async { @@ -240,67 +229,62 @@ class AuthenticationRepository with LoggerMixin { /// /// Check authentication status first then try to logout. /// Do nothing if already unauthenticated. - /// - /// # Exception - /// - /// * **[HttpRequestFailedException]** if http requests failed. - /// - /// # Sealed Exception - /// - /// * **[LogoutException]** if failed to logout. - Future logout() async { - if (_authedUser == null) { - return; - } - final netClient = NetClientProvider.build( - userLoginInfo: UserLoginInfo( - username: _authedUser!.username, - uid: _authedUser!.uid, - email: _authedUser!.email, - ), - logout: true, - ); - final resp = await netClient.get(_checkAuthUrl); - if (resp.statusCode != HttpStatus.ok) { - throw HttpRequestFailedException(resp.statusCode); - } - final document = parseHtmlDocument(resp.data as String); - final userInfo = _parseUserInfoFromDocument(document); - if (userInfo == null) { - // Not logged in. - - _authedUser = null; - _controller.add(AuthenticationStatus.unauthenticated); - return; - } - final formHash = _formHashRe - .firstMatch(document.body?.innerHtml ?? '') - ?.namedGroup('FormHash'); - if (formHash == null) { - throw LogoutFormHashNotFoundException(); - } - - final logoutResp = await netClient.get(_buildLogoutUrl(formHash)); - if (logoutResp.statusCode != HttpStatus.ok) { - throw HttpRequestFailedException(logoutResp.statusCode); - } - final logoutDocument = parseHtmlDocument(logoutResp.data as String); - final logoutMessage = logoutDocument.getElementById('messagetext'); - if (logoutMessage == null || !logoutMessage.innerHtmlEx().contains('已退出')) { - // Here we'd better to check the failed reason, but it's ok without it. - throw LogoutFailedException(); - } - - await getIt.get().deleteCookieByUid(_authedUser!.uid!); - - final settings = getIt.get(); - await settings.deleteValue(SettingsKeys.loginUsername); - await settings.deleteValue(SettingsKeys.loginUid); - await settings.deleteValue(SettingsKeys.loginEmail); - - _authedUser = null; - _controller.add(AuthenticationStatus.unauthenticated); - } + AsyncVoidEither logout() => AsyncVoidEither(() async { + if (_authedUser == null) { + return rightVoid(); + } + final netClient = NetClientProvider.build( + userLoginInfo: UserLoginInfo( + username: _authedUser!.username, + uid: _authedUser!.uid, + email: _authedUser!.email, + ), + logout: true, + ); + final resp = await netClient.get(_checkAuthUrl); + if (resp.statusCode != HttpStatus.ok) { + return left(HttpRequestFailedException(resp.statusCode)); + } + final document = parseHtmlDocument(resp.data as String); + final userInfo = _parseUserInfoFromDocument(document); + if (userInfo == null) { + // Not logged in. + + _authedUser = null; + _controller.add(AuthenticationStatus.unauthenticated); + return rightVoid(); + } + final formHash = _formHashRe + .firstMatch(document.body?.innerHtml ?? '') + ?.namedGroup('FormHash'); + if (formHash == null) { + return left(LogoutFormHashNotFoundException()); + } + + final logoutResp = await netClient.get(_buildLogoutUrl(formHash)); + if (logoutResp.statusCode != HttpStatus.ok) { + return left(HttpRequestFailedException(logoutResp.statusCode)); + } + final logoutDocument = parseHtmlDocument(logoutResp.data as String); + final logoutMessage = logoutDocument.getElementById('messagetext'); + if (logoutMessage == null || + !logoutMessage.innerHtmlEx().contains('已退出')) { + // TODO: Here we'd better to check the failed reason. + return left(LogoutFailedException()); + } + + await getIt.get().deleteCookieByUid(_authedUser!.uid!); + + final settings = getIt.get(); + await settings.deleteValue(SettingsKeys.loginUsername); + await settings.deleteValue(SettingsKeys.loginUid); + await settings.deleteValue(SettingsKeys.loginEmail); + + _authedUser = null; + _controller.add(AuthenticationStatus.unauthenticated); + + return rightVoid(); + }); /// Parse html [document], find current logged in user uid in it. /// @@ -344,30 +328,18 @@ class AuthenticationRepository with LoggerMixin { /// Parse the login result. /// /// Do nothing if login succeed. - /// - /// Throw exception if login failed. - /// - /// # Sealed Exception - /// - /// * **[LoginException]** throw when parse result is not "login success". - void _parseAndThrowLoginResult(LoginResult loginResult) { - switch (loginResult) { - case LoginResult.success: - return; - case LoginResult.incorrectCaptcha: - throw LoginIncorrectCaptchaException(); - case LoginResult.invalidUsernamePassword: - throw LoginInvalidCredentialException(); - case LoginResult.incorrectQuestionOrAnswer: - throw LoginIncorrectSecurityQuestionException(); - case LoginResult.attemptLimit: - throw LoginAttemptLimitException(); - case LoginResult.otherError: - throw LoginOtherErrorException('other error'); - case LoginResult.unknown: - throw LoginOtherErrorException('unknown result'); - } - } + SyncVoidEither _mapLoginResult(LoginResult loginResult) => + switch (loginResult) { + LoginResult.success => rightVoid(), + LoginResult.incorrectCaptcha => left(LoginIncorrectCaptchaException()), + LoginResult.invalidUsernamePassword => + left(LoginInvalidCredentialException()), + LoginResult.incorrectQuestionOrAnswer => + left(LoginIncorrectSecurityQuestionException()), + LoginResult.attemptLimit => left(LoginAttemptLimitException()), + LoginResult.otherError => left(LoginOtherErrorException('other error')), + LoginResult.unknown => left(LoginOtherErrorException('unknown result')), + }; Future _saveLoggedUserInfo(UserLoginInfo userInfo) async { debug('save logged user info: $userInfo'); diff --git a/lib/features/authentication/repository/exceptions/exceptions.dart b/lib/features/authentication/repository/exceptions/exceptions.dart deleted file mode 100644 index 5b02b5ad..00000000 --- a/lib/features/authentication/repository/exceptions/exceptions.dart +++ /dev/null @@ -1,53 +0,0 @@ -/// Basic exception class of login -sealed class LoginException implements Exception {} - -/// The form hash used in login progress is not found. -final class LoginFormHashNotFoundException implements LoginException {} - -/// Found form hash, but it's not in the expect format. -final class LoginInvalidFormHashException implements LoginException {} - -/// The login result message of login progress is not found. -/// -/// Indicating that we do not know whether we logged in successful or not. -final class LoginMessageNotFoundException implements LoginException {} - -/// The captcha user texted is incorrect. -final class LoginIncorrectCaptchaException implements LoginException {} - -/// Incorrect password or account. -final class LoginInvalidCredentialException implements LoginException {} - -/// Security question or its answer is incorrect. -final class LoginIncorrectSecurityQuestionException implements LoginException {} - -/// Reached the limit of login attempt. -/// -/// Maybe locked in 20 minutes. -final class LoginAttemptLimitException implements LoginException {} - -/// User info not found when try to login after login seems success. -/// -/// Now we should update the logged user info but this exception means we can -/// not found the logged user info. -final class LoginUserInfoNotFoundException implements LoginException {} - -/// Some other exception that not recognized. -final class LoginOtherErrorException implements LoginException { - /// Constructor. - LoginOtherErrorException(this.message); - - /// Message to describe the error. - final String message; -} - -/// Basic exception class of logout. -sealed class LogoutException implements Exception {} - -/// The form hash used to logout is not found. -final class LogoutFormHashNotFoundException implements LogoutException {} - -/// Failed to logout. -/// -/// Nearly impossible to happen. -final class LogoutFailedException implements LogoutException {} diff --git a/lib/features/authentication/view/login_page.dart b/lib/features/authentication/view/login_page.dart index 73445526..c0165a1c 100644 --- a/lib/features/authentication/view/login_page.dart +++ b/lib/features/authentication/view/login_page.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/features/authentication/bloc/authentication_bloc.dart'; -import 'package:tsdm_client/features/authentication/repository/exceptions/exceptions.dart'; import 'package:tsdm_client/features/authentication/widgets/login_form.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/show_toast.dart'; @@ -52,7 +52,7 @@ class _LoginPageState extends State { context.t.loginPage.loginFailed, LoginOtherErrorException() => context.t.loginPage.loginResultOtherErrors, - null => context.t.general.failedToLoad, + _ => context.t.general.failedToLoad, }; showSnackBar(context: context, message: errorText); context diff --git a/lib/features/profile/bloc/profile_bloc.dart b/lib/features/profile/bloc/profile_bloc.dart index 77e4f8f6..6083a33a 100644 --- a/lib/features/profile/bloc/profile_bloc.dart +++ b/lib/features/profile/bloc/profile_bloc.dart @@ -4,7 +4,6 @@ import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/extensions/string.dart'; import 'package:tsdm_client/extensions/universal_html.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; -import 'package:tsdm_client/features/authentication/repository/exceptions/exceptions.dart'; import 'package:tsdm_client/features/profile/models/models.dart'; import 'package:tsdm_client/features/profile/repository/profile_repository.dart'; import 'package:tsdm_client/utils/logger.dart'; @@ -12,11 +11,10 @@ import 'package:universal_html/html.dart' as uh; part 'profile_bloc.mapper.dart'; part 'profile_event.dart'; - part 'profile_state.dart'; /// Emitter -typedef ProfileEmitter = Emitter; +typedef _Emitter = Emitter; /// Bloc of user profile page. /// @@ -32,9 +30,14 @@ class ProfileBloc extends Bloc with LoggerMixin { }) : _profileRepository = profileRepository, _authenticationRepository = authenticationRepository, super(const ProfileState()) { - on(_onProfileLoadRequested); - on(_onProfileRefreshRequested); - on(_onProfileLogoutRequested); + on( + (event, emit) => switch (event) { + ProfileLoadRequested(:final username, :final uid) => + _onLoadRequested(emit, username: username, uid: uid), + ProfileRefreshRequested() => _onRefreshRequested(emit), + ProfileLogoutRequested() => _onLogoutRequested(emit), + }, + ); } final ProfileRepository _profileRepository; @@ -43,13 +46,12 @@ class ProfileBloc extends Bloc with LoggerMixin { final RegExp _birthdayRe = RegExp(r'((?\d+) 年)? ?((?\d+) 月)? ?((?\d+) 日)?'); - Future _onProfileLoadRequested( - ProfileLoadRequested event, - ProfileEmitter emit, - ) async { - if (event.username == null && - event.uid == null && - _profileRepository.hasCache()) { + Future _onLoadRequested( + _Emitter emit, { + String? username, + String? uid, + }) async { + if (username == null && uid == null && _profileRepository.hasCache()) { final userProfile = _buildProfile(_profileRepository.getCache()!); final (unreadNoticeCount, hasUnreadMessage) = _buildUnreadInfoStatus(_profileRepository.getCache()!); @@ -66,8 +68,8 @@ class ProfileBloc extends Bloc with LoggerMixin { try { emit(state.copyWith(status: ProfileStatus.loading)); final document = await _profileRepository.fetchProfile( - username: event.username, - uid: event.uid, + username: username, + uid: uid, ); if (document == null) { emit(state.copyWith(status: ProfileStatus.needLogin)); @@ -76,7 +78,7 @@ class ProfileBloc extends Bloc with LoggerMixin { final userProfile = _buildProfile(document); if (userProfile == null) { error('failed to parse user profile'); - emit(state.copyWith(status: ProfileStatus.failed)); + emit(state.copyWith(status: ProfileStatus.failure)); return; } final (unreadNoticeCount, hasUnreadMessage) = @@ -91,14 +93,11 @@ class ProfileBloc extends Bloc with LoggerMixin { ); } on HttpRequestFailedException catch (e) { error('failed to load profile: $e'); - emit(state.copyWith(status: ProfileStatus.failed)); + emit(state.copyWith(status: ProfileStatus.failure)); } } - Future _onProfileRefreshRequested( - ProfileRefreshRequested event, - ProfileEmitter emit, - ) async { + Future _onRefreshRequested(_Emitter emit) async { try { emit(state.copyWith(status: ProfileStatus.loading)); final document = await _profileRepository.fetchProfile(force: true); @@ -109,7 +108,7 @@ class ProfileBloc extends Bloc with LoggerMixin { final userProfile = _buildProfile(document); if (userProfile == null) { error('failed to parse user profile'); - emit(state.copyWith(status: ProfileStatus.failed)); + emit(state.copyWith(status: ProfileStatus.failure)); return; } final (unreadNoticeCount, hasUnreadMessage) = @@ -124,35 +123,25 @@ class ProfileBloc extends Bloc with LoggerMixin { ); } on HttpRequestFailedException catch (e) { error('failed to refresh profile: $e'); - emit(state.copyWith(status: ProfileStatus.failed)); + emit(state.copyWith(status: ProfileStatus.failure)); return; } } - Future _onProfileLogoutRequested( - ProfileLogoutRequested event, - ProfileEmitter emit, - ) async { - emit(state.copyWith(status: ProfileStatus.logout)); - try { - await _authenticationRepository.logout(); - _profileRepository.logout(); - emit(state.copyWith(status: ProfileStatus.needLogin)); - } on HttpRequestFailedException catch (e) { - emit( - state.copyWith( - status: ProfileStatus.success, - failedToLogoutReason: e, - ), - ); - } on LogoutException catch (e) { - emit( - state.copyWith( - status: ProfileStatus.success, - failedToLogoutReason: e, - ), - ); - } + Future _onLogoutRequested(_Emitter emit) async { + emit(state.copyWith(status: ProfileStatus.loggingOut)); + await _authenticationRepository.logout().match( + (e) { + handle(e); + emit( + state.copyWith( + status: ProfileStatus.success, + failedToLogoutReason: e, + ), + ); + }, + (_) => emit(state.copyWith(status: ProfileStatus.needLogin)), + ).run(); } /// Build a user profile [UserProfile] from given html [document]. diff --git a/lib/features/profile/bloc/profile_state.dart b/lib/features/profile/bloc/profile_state.dart index cc860fa8..3a107872 100644 --- a/lib/features/profile/bloc/profile_state.dart +++ b/lib/features/profile/bloc/profile_state.dart @@ -12,13 +12,13 @@ enum ProfileStatus { needLogin, /// Processing logout action - logout, + loggingOut, /// Login or logout succeed. success, /// Login or logout failed. - failed, + failure, } /// State of profile page of the app. diff --git a/lib/features/profile/view/profile_page.dart b/lib/features/profile/view/profile_page.dart index c523b812..0095bb7e 100644 --- a/lib/features/profile/view/profile_page.dart +++ b/lib/features/profile/view/profile_page.dart @@ -788,7 +788,7 @@ class _ProfilePageState extends State { )..add(ProfileLoadRequested(username: widget.username, uid: widget.uid)), child: BlocConsumer( listener: (context, state) { - if (state.status == ProfileStatus.failed) { + if (state.status == ProfileStatus.failure) { showFailedToLoadSnackBar(context); } }, @@ -800,9 +800,9 @@ class _ProfilePageState extends State { ProfileStatus.initial || ProfileStatus.loading || ProfileStatus.needLogin || - ProfileStatus.failed => + ProfileStatus.failure => AppBar(title: Text(context.t.profilePage.title)), - ProfileStatus.success || ProfileStatus.logout => null, + ProfileStatus.success || ProfileStatus.loggingOut => null, }; // Main content of user profile. @@ -818,7 +818,7 @@ class _ProfilePageState extends State { context.read().add(ProfileRefreshRequested()); }, ), - ProfileStatus.failed => buildRetryButton(context, () { + ProfileStatus.failure => buildRetryButton(context, () { context.read().add( ProfileLoadRequested( username: widget.username, @@ -826,11 +826,11 @@ class _ProfilePageState extends State { ), ); }), - ProfileStatus.success || ProfileStatus.logout => _buildContent( + ProfileStatus.success || ProfileStatus.loggingOut => _buildContent( context, state, failedToLogoutReason: state.failedToLogoutReason, - logout: state.status == ProfileStatus.logout, + logout: state.status == ProfileStatus.loggingOut, ), }; diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 2633bf4d..9fb68ae7 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/instance.dart'; /// Logger mixin. @@ -54,13 +55,15 @@ mixin LoggerMixin { } /// Exception - void handle( - Object exception, [ - StackTrace? stackTrace, - dynamic msg, - ]) { + void handle(AppException exception) { talker ..error('$runtimeType: handle error:') - ..handle(exception, stackTrace, msg); + ..handle(exception, exception.stackTrace, exception.message); + } + + /// Handle [exception] then run [callback]. + void handleThen(AppException exception, VoidCallback callback) { + handle(exception); + callback(); } } diff --git a/pubspec.lock b/pubspec.lock index f0a72e6c..17ade593 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -688,6 +688,14 @@ packages: url: "https://pub.dev" source: hosted version: "10.7.0" + fpdart: + dependency: "direct main" + description: + name: fpdart + sha256: "7413acc5a6569a3fe8277928fc7487f3198530f0c4e635d0baef199ea36e8ee9" + url: "https://pub.dev" + source: hosted + version: "1.1.0" freezed_annotation: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 498aece3..30b7591e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: flutter_markdown: ^0.7.3+1 flutter_svg: ^2.0.10+1 font_awesome_flutter: ^10.7.0 + fpdart: ^1.1.0 get_it: ^7.7.0 gitsumu: ^0.5.0 go_router: ^14.2.3