diff --git a/lib/ui/viewmodels/animes/followed_anime_controller.dart b/lib/ui/viewmodels/animes/followed_anime_controller.dart index 6aeb8ce..f4343b2 100644 --- a/lib/ui/viewmodels/animes/followed_anime_controller.dart +++ b/lib/ui/viewmodels/animes/followed_anime_controller.dart @@ -1,5 +1,5 @@ import 'package:application/ui/viewmodels/generic_controller.dart'; -import 'package:application/ui/viewmodels/member_controller.dart'; +import 'package:application/ui/viewmodels/auth_viewmodel.dart'; import 'package:application/data/models/anime_dto.dart'; import 'package:application/data/models/pageable_dto.dart'; import 'package:application/core/network/api_client.dart'; @@ -11,7 +11,6 @@ class FollowedAnimeController extends GenericController { : _client = client ?? const ApiClient(); static final FollowedAnimeController instance = FollowedAnimeController(); final ApiClient _client; - bool _isRetry = false; int get limit => wb.WidgetBuilder.getDeviceType() == wb.DeviceType.mobile ? 9 : 24; @@ -25,25 +24,26 @@ class FollowedAnimeController extends GenericController { } @override - Future, int>> fetchItems() async { - final ApiResult result = await _client.getPage( + Future, int>> fetchItems() => + _fetchItems(isRetry: false); + + Future, int>> _fetchItems({ + required final bool isRetry, + }) async { + final result = await _client.getPage( '/v1/animes', query: {'page': page, 'limit': limit}, - token: MemberController.instance.member?.token, + token: AuthViewModel.instance.member?.token, ); if (result is ApiFailure && result.statusCode == 401) { - if (_isRetry) { + if (isRetry) { throw Exception('Unauthorized after retry'); } - - _isRetry = true; - await MemberController.instance.login(); - return fetchItems(); + await AuthViewModel.instance.login(); + return _fetchItems(isRetry: true); } - _isRetry = false; - return switch (result) { ApiSuccess(:final data) => Pair, int>( data.data.map( diff --git a/lib/ui/viewmodels/animes/missed_anime_controller.dart b/lib/ui/viewmodels/animes/missed_anime_controller.dart index e626fda..848ced4 100644 --- a/lib/ui/viewmodels/animes/missed_anime_controller.dart +++ b/lib/ui/viewmodels/animes/missed_anime_controller.dart @@ -1,5 +1,5 @@ import 'package:application/ui/viewmodels/generic_controller.dart'; -import 'package:application/ui/viewmodels/member_controller.dart'; +import 'package:application/ui/viewmodels/auth_viewmodel.dart'; import 'package:application/data/models/missed_anime_dto.dart'; import 'package:application/data/models/pageable_dto.dart'; import 'package:application/core/constants/constant.dart'; @@ -12,7 +12,6 @@ class MissedAnimeController extends GenericController { : _client = client ?? const ApiClient(); static final MissedAnimeController instance = MissedAnimeController(); final ApiClient _client; - bool _isRetry = false; int get limit => Constant.isAndroidOrIOS && @@ -29,25 +28,26 @@ class MissedAnimeController extends GenericController { } @override - Future, int>> fetchItems() async { - final ApiResult result = await _client.getPage( + Future, int>> fetchItems() => + _fetchItems(isRetry: false); + + Future, int>> _fetchItems({ + required final bool isRetry, + }) async { + final result = await _client.getPage( '/v1/animes/missed', query: {'page': page, 'limit': limit}, - token: MemberController.instance.member!.token, + token: AuthViewModel.instance.member!.token, ); if (result is ApiFailure && result.statusCode == 401) { - if (_isRetry) { + if (isRetry) { throw Exception('Unauthorized after retry'); } - - _isRetry = true; - await MemberController.instance.login(); - return fetchItems(); + await AuthViewModel.instance.login(); + return _fetchItems(isRetry: true); } - _isRetry = false; - return switch (result) { ApiSuccess(:final data) => Pair, int>( diff --git a/lib/ui/viewmodels/auth_viewmodel.dart b/lib/ui/viewmodels/auth_viewmodel.dart new file mode 100644 index 0000000..47032a3 --- /dev/null +++ b/lib/ui/viewmodels/auth_viewmodel.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:application/core/analytics/analytics.dart'; +import 'package:application/core/network/api_client.dart'; +import 'package:application/core/network/api_result.dart'; +import 'package:application/data/models/enums/config_property_key.dart'; +import 'package:application/data/models/member_dto.dart'; +import 'package:application/ui/viewmodels/shared_preferences_controller.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; + +class AuthViewModel { + AuthViewModel({ApiClient? client, Analytics? analytics}) + : _client = client ?? const ApiClient(), + _analytics = analytics ?? const Analytics(); + static AuthViewModel instance = AuthViewModel(); + final ApiClient _client; + final Analytics _analytics; + final StreamController streamController = + StreamController.broadcast(); + String? identifier; + MemberDto? member; + + Future init({final bool afterDelete = false}) async { + identifier = + SharedPreferencesController.instance.getString( + ConfigPropertyKey.identifier, + ) ?? + await register(); + + try { + await login(); + } on HttpException catch (e) { + debugPrint('Failed to login: $e'); + + if (!afterDelete) { + // Move the current identifier to old identifier + final String? oldIdentifier = identifier; + await SharedPreferencesController.instance.remove( + ConfigPropertyKey.identifier, + ); + + await SharedPreferencesController.instance.setString( + ConfigPropertyKey.oldIdentifier, + oldIdentifier!, + ); + await init(afterDelete: true); + } + } on TimeoutException catch (e) { + debugPrint('Failed to login: $e'); + rethrow; + } on http.ClientException catch (e) { + debugPrint('Failed to login: $e'); + rethrow; + } + } + + Future register() async { + final ApiResult result = await _client.post( + '/v1/members/register', + ); + + return switch (result) { + ApiSuccess(:final data) => await _handleRegisterSuccess( + data, + ), + ApiFailure(:final error) => throw HttpException(error), + }; + } + + Future _handleRegisterSuccess(final http.Response response) async { + final String identifier = + (jsonDecode(utf8.decode(response.bodyBytes)) + as Map)['identifier'] + as String; + await SharedPreferencesController.instance.setString( + ConfigPropertyKey.identifier, + identifier, + ); + _analytics.logSignUp(); + return identifier; + } + + String get _device { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return 'android'; + case TargetPlatform.iOS: + return 'ios'; + case TargetPlatform.macOS: + return 'macos'; + case TargetPlatform.windows: + return 'windows'; + case TargetPlatform.linux: + return 'linux'; + case TargetPlatform.fuchsia: + return 'fuchsia'; + } + } + + Future testLogin(final String identifier) async { + final PackageInfo packageInfo = await PackageInfo.fromPlatform(); + + final ApiResult result = await _client.post( + '/v1/members/login', + headers: { + 'X-App-Version': '${packageInfo.version}+${packageInfo.buildNumber}', + 'X-Device': _device, + 'X-Locale': Platform.localeName, + }, + body: identifier, + ); + + return switch (result) { + ApiSuccess(:final data) => data, + ApiFailure(:final statusCode) + when statusCode == HttpStatus.notFound => + throw const HttpException('Failed to login, identifier not found'), + ApiFailure(:final error) => throw http.ClientException( + error, + ), + }; + } + + Future login({final String? identifier}) async { + final http.Response response = await testLogin( + identifier ?? this.identifier!, + ); + final Map json = + jsonDecode(utf8.decode(response.bodyBytes)) as Map; + + if (identifier != null) { + this.identifier = identifier; + await SharedPreferencesController.instance.setString( + ConfigPropertyKey.identifier, + identifier, + ); + } + + member = MemberDto.fromJson(json); + streamController.add(member!); + _analytics.logLogin(); + } +} diff --git a/lib/ui/viewmodels/conflict_email_exception.dart b/lib/ui/viewmodels/conflict_email_exception.dart new file mode 100644 index 0000000..025ad3f --- /dev/null +++ b/lib/ui/viewmodels/conflict_email_exception.dart @@ -0,0 +1,3 @@ +class ConflictEmailException implements Exception { + const ConflictEmailException(); +} diff --git a/lib/ui/viewmodels/email_viewmodel.dart b/lib/ui/viewmodels/email_viewmodel.dart new file mode 100644 index 0000000..6edd26c --- /dev/null +++ b/lib/ui/viewmodels/email_viewmodel.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:application/core/network/api_client.dart'; +import 'package:application/core/network/api_result.dart'; +import 'package:application/ui/viewmodels/auth_viewmodel.dart'; +import 'package:application/ui/viewmodels/conflict_email_exception.dart'; +import 'package:http/http.dart' as http; + +class EmailViewModel { + EmailViewModel({ApiClient? client}) : _client = client ?? const ApiClient(); + + static final EmailViewModel instance = EmailViewModel(); + final ApiClient _client; + + Future associateEmail( + final String email, { + final bool isRetry = false, + }) async { + final ApiResult result = await _client.post( + '/v1/members/associate-email', + token: AuthViewModel.instance.member!.token, + body: email, + ); + + switch (result) { + case ApiFailure(:final statusCode) + when statusCode == HttpStatus.unauthorized: + if (isRetry) { + throw Exception('Unauthorized after retry'); + } + await AuthViewModel.instance.login(); + return associateEmail(email, isRetry: true); + + case ApiFailure(:final statusCode) + when statusCode == HttpStatus.conflict: + throw const ConflictEmailException(); + + case ApiSuccess(:final data): + return _extractUuid(data); + + case ApiFailure(:final error): + throw HttpException(error); + } + } + + Future forgotIdentifier( + final String email, { + final bool isRetry = false, + }) async { + final ApiResult result = await _client.post( + '/v1/members/forgot-identifier', + token: AuthViewModel.instance.member!.token, + body: email, + ); + + switch (result) { + case ApiFailure(:final statusCode) + when statusCode == HttpStatus.unauthorized: + if (isRetry) { + throw Exception('Unauthorized after retry'); + } + await AuthViewModel.instance.login(); + return forgotIdentifier(email, isRetry: true); + + case ApiFailure(:final statusCode) + when statusCode == HttpStatus.conflict: + throw const ConflictEmailException(); + + case ApiSuccess(:final data): + return _extractUuid(data); + + case ApiFailure(:final error): + throw HttpException(error); + } + } + + Future validateAction( + final String uuid, + final String code, { + final bool isRetry = false, + }) async { + final ApiResult result = await _client.post( + '/v1/member-actions/validate?uuid=$uuid', + token: AuthViewModel.instance.member!.token, + body: code, + ); + + switch (result) { + case ApiFailure(:final statusCode) + when statusCode == HttpStatus.unauthorized: + if (isRetry) { + throw Exception('Unauthorized after retry'); + } + await AuthViewModel.instance.login(); + return validateAction(uuid, code, isRetry: true); + + case ApiSuccess(): + return; + + case ApiFailure(:final error): + throw HttpException(error); + } + } + + String _extractUuid(final http.Response response) { + return (jsonDecode(utf8.decode(response.bodyBytes)) + as Map)['uuid'] + as String; + } +} diff --git a/lib/ui/viewmodels/episodes/followed_episode_controller.dart b/lib/ui/viewmodels/episodes/followed_episode_controller.dart index 2ab9c3a..1f785f5 100644 --- a/lib/ui/viewmodels/episodes/followed_episode_controller.dart +++ b/lib/ui/viewmodels/episodes/followed_episode_controller.dart @@ -1,5 +1,5 @@ import 'package:application/ui/viewmodels/generic_controller.dart'; -import 'package:application/ui/viewmodels/member_controller.dart'; +import 'package:application/ui/viewmodels/auth_viewmodel.dart'; import 'package:application/data/models/episode_mapping_dto.dart'; import 'package:application/data/models/pageable_dto.dart'; import 'package:application/core/network/api_client.dart'; @@ -11,7 +11,6 @@ class FollowedEpisodeController extends GenericController { : _client = client ?? const ApiClient(); static final FollowedEpisodeController instance = FollowedEpisodeController(); final ApiClient _client; - bool _isRetry = false; int get limit => wb.WidgetBuilder.getDeviceType() == wb.DeviceType.mobile ? 9 : 16; @@ -25,25 +24,26 @@ class FollowedEpisodeController extends GenericController { } @override - Future, int>> fetchItems() async { - final ApiResult result = await _client.getPage( + Future, int>> fetchItems() => + _fetchItems(isRetry: false); + + Future, int>> _fetchItems({ + required final bool isRetry, + }) async { + final result = await _client.getPage( '/v1/episode-mappings', query: {'page': page, 'limit': limit}, - token: MemberController.instance.member?.token, + token: AuthViewModel.instance.member?.token, ); if (result is ApiFailure && result.statusCode == 401) { - if (_isRetry) { + if (isRetry) { throw Exception('Unauthorized after retry'); } - - _isRetry = true; - await MemberController.instance.login(); - return fetchItems(); + await AuthViewModel.instance.login(); + return _fetchItems(isRetry: true); } - _isRetry = false; - return switch (result) { ApiSuccess(:final data) => Pair, int>( diff --git a/lib/ui/viewmodels/follow_viewmodel.dart b/lib/ui/viewmodels/follow_viewmodel.dart new file mode 100644 index 0000000..8290553 --- /dev/null +++ b/lib/ui/viewmodels/follow_viewmodel.dart @@ -0,0 +1,198 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:application/core/network/api_client.dart'; +import 'package:application/core/network/api_result.dart'; +import 'package:application/ui/viewmodels/auth_viewmodel.dart'; +import 'package:application/ui/viewmodels/member_viewmodel.dart'; +import 'package:http/http.dart' as http; + +/// Handles follow/unfollow operations for animes and episodes. +class FollowViewModel { + FollowViewModel({ApiClient? client}) : _client = client ?? const ApiClient(); + + static final FollowViewModel instance = FollowViewModel(); + final ApiClient _client; + + Future followAnime( + final String anime, { + final bool loadMemberData = true, + final bool isRetry = false, + }) async { + final auth = AuthViewModel.instance; + if (auth.member!.followedAnimes.contains(anime)) { + return; + } + + final result = await _client.put( + '/v1/members/animes', + auth.member!.token, + jsonEncode({'uuid': anime}), + ); + + switch (result) { + case ApiFailure(:final statusCode) + when statusCode == HttpStatus.unauthorized: + if (isRetry) { + throw Exception('Unauthorized after retry'); + } + await auth.login(); + return followAnime( + anime, + loadMemberData: loadMemberData, + isRetry: true, + ); + + case ApiSuccess(): + auth.member!.followedAnimes.add(anime); + if (loadMemberData) { + await MemberViewModel.instance.refresh(); + } else { + auth.streamController.add(auth.member!); + } + + case ApiFailure(:final error): + throw HttpException(error); + } + } + + Future unfollowAnime( + final String anime, { + final bool isRetry = false, + }) async { + final auth = AuthViewModel.instance; + final result = await _client.delete( + '/v1/members/animes', + auth.member!.token, + jsonEncode({'uuid': anime}), + ); + + switch (result) { + case ApiFailure(:final statusCode) + when statusCode == HttpStatus.unauthorized: + if (isRetry) { + throw Exception('Unauthorized after retry'); + } + await auth.login(); + return unfollowAnime(anime, isRetry: true); + + case ApiSuccess(): + auth.member!.followedAnimes.remove(anime); + await MemberViewModel.instance.refresh(); + + case ApiFailure(:final error): + throw HttpException(error); + } + } + + Future followAllEpisodes( + final String anime, { + final bool isRetry = false, + }) async { + final auth = AuthViewModel.instance; + if (!auth.member!.followedAnimes.contains(anime)) { + await followAnime(anime, loadMemberData: false); + } + + final result = await _client.put( + '/v1/members/follow-all-episodes', + auth.member!.token, + jsonEncode({'uuid': anime}), + ); + + switch (result) { + case ApiFailure(:final statusCode) + when statusCode == HttpStatus.unauthorized: + if (isRetry) { + throw Exception('Unauthorized after retry'); + } + await auth.login(); + return followAllEpisodes(anime, isRetry: true); + + case ApiSuccess(:final data): + final json = + jsonDecode(utf8.decode(data.bodyBytes)) as Map; + final newEpisodes = List.from(json['data'] as List); + auth.member!.followedEpisodes.addAll(newEpisodes); + await MemberViewModel.instance.refresh(); + + case ApiFailure(:final error): + throw HttpException(error); + } + } + + Future followEpisode( + final String anime, + final String episode, { + final bool refreshAfterFollow = true, + final bool isRetry = false, + }) async { + final auth = AuthViewModel.instance; + if (auth.member!.followedEpisodes.contains(episode)) { + return; + } + + if (!auth.member!.followedAnimes.contains(anime)) { + await followAnime(anime, loadMemberData: false); + } + + final result = await _client.put( + '/v1/members/episodes', + auth.member!.token, + jsonEncode({'uuid': episode}), + ); + + switch (result) { + case ApiFailure(:final statusCode) + when statusCode == HttpStatus.unauthorized: + if (isRetry) { + throw Exception('Unauthorized after retry'); + } + await auth.login(); + return followEpisode( + anime, + episode, + refreshAfterFollow: refreshAfterFollow, + isRetry: true, + ); + + case ApiSuccess(): + auth.member!.followedEpisodes.add(episode); + if (refreshAfterFollow) { + await MemberViewModel.instance.refresh(); + } + + case ApiFailure(:final error): + throw HttpException(error); + } + } + + Future unfollowEpisode( + final String episode, { + final bool isRetry = false, + }) async { + final auth = AuthViewModel.instance; + final result = await _client.delete( + '/v1/members/episodes', + auth.member!.token, + jsonEncode({'uuid': episode}), + ); + + switch (result) { + case ApiFailure(:final statusCode) + when statusCode == HttpStatus.unauthorized: + if (isRetry) { + throw Exception('Unauthorized after retry'); + } + await auth.login(); + return unfollowEpisode(episode, isRetry: true); + + case ApiSuccess(): + auth.member!.followedEpisodes.remove(episode); + await MemberViewModel.instance.refresh(); + + case ApiFailure(:final error): + throw HttpException(error); + } + } +} diff --git a/lib/ui/viewmodels/member_controller.dart b/lib/ui/viewmodels/member_controller.dart index 4c9508b..a1ffa05 100644 --- a/lib/ui/viewmodels/member_controller.dart +++ b/lib/ui/viewmodels/member_controller.dart @@ -1,581 +1,105 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; +import 'dart:typed_data'; import 'package:application/core/analytics/analytics.dart'; import 'package:application/core/network/api_client.dart'; -import 'package:application/core/network/api_result.dart'; -import 'package:application/data/models/anime_dto.dart'; -import 'package:application/data/models/enums/config_property_key.dart'; -import 'package:application/data/models/episode_mapping_dto.dart'; import 'package:application/data/models/member_dto.dart'; -import 'package:application/data/models/missed_anime_dto.dart'; -import 'package:application/data/models/refresh_member_dto.dart'; -import 'package:application/l10n/app_localizations.dart'; -import 'package:application/ui/viewmodels/animes/followed_anime_controller.dart'; -import 'package:application/ui/viewmodels/animes/missed_anime_controller.dart'; -import 'package:application/ui/viewmodels/episodes/followed_episode_controller.dart'; -import 'package:application/ui/viewmodels/shared_preferences_controller.dart'; -import 'package:application/ui/views/crop_view.dart'; -import 'package:crop_your_image/crop_your_image.dart'; -import 'package:flutter/foundation.dart'; +import 'package:application/ui/viewmodels/auth_viewmodel.dart'; +import 'package:application/ui/viewmodels/email_viewmodel.dart'; +import 'package:application/ui/viewmodels/follow_viewmodel.dart'; +import 'package:application/ui/viewmodels/member_viewmodel.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -import 'package:image_picker/image_picker.dart'; -import 'package:package_info_plus/package_info_plus.dart'; +/// Facade controller that delegates to dedicated ViewModels. +/// +/// Maintains backward compatibility while the app migrates to the +/// individual ViewModels. New code should use the specific ViewModels +/// directly. class MemberController { MemberController({ApiClient? client, Analytics? analytics}) : _client = client ?? const ApiClient(), _analytics = analytics ?? const Analytics(); + static MemberController instance = MemberController(); + // ignore: unused_field — kept for backward-compatible DI constructor final ApiClient _client; + // ignore: unused_field — kept for backward-compatible DI constructor final Analytics _analytics; - final StreamController streamController = - StreamController.broadcast(); - String? identifier; - MemberDto? member; - bool isImageUploadLoading = false; - - Future init({final bool afterDelete = false}) async { - identifier = - SharedPreferencesController.instance.getString( - ConfigPropertyKey.identifier, - ) ?? - await register(); - - try { - await login(); - } on HttpException catch (e) { - debugPrint('Failed to login: $e'); - - if (!afterDelete) { - // Move the current identifier to old identifier - final String? oldIdentifier = identifier; - await SharedPreferencesController.instance.remove( - ConfigPropertyKey.identifier, - ); - - await SharedPreferencesController.instance.setString( - ConfigPropertyKey.oldIdentifier, - oldIdentifier!, - ); - await init(afterDelete: true); - } - } on TimeoutException catch (e) { - debugPrint('Failed to login: $e'); - rethrow; - } on http.ClientException catch (e) { - debugPrint('Failed to login: $e'); - rethrow; - } - } - - Future register() async { - final ApiResult result = await _client.post( - '/v1/members/register', - ); - - return switch (result) { - ApiSuccess(:final data) => await _handleRegisterSuccess( - data, - ), - ApiFailure(:final error) => throw HttpException(error), - }; - } - - Future _handleRegisterSuccess(final http.Response response) async { - final String identifier = - (jsonDecode(utf8.decode(response.bodyBytes)) - as Map)['identifier'] - as String; - await SharedPreferencesController.instance.setString( - ConfigPropertyKey.identifier, - identifier, - ); - _analytics.logSignUp(); - return identifier; - } - - String get _device { - switch (defaultTargetPlatform) { - case TargetPlatform.android: - return 'android'; - case TargetPlatform.iOS: - return 'ios'; - case TargetPlatform.macOS: - return 'macos'; - case TargetPlatform.windows: - return 'windows'; - case TargetPlatform.linux: - return 'linux'; - case TargetPlatform.fuchsia: - return 'fuchsia'; - } - } - - Future testLogin(final String identifier) async { - final PackageInfo packageInfo = await PackageInfo.fromPlatform(); - - final ApiResult result = await _client.post( - '/v1/members/login', - headers: { - 'X-App-Version': '${packageInfo.version}+${packageInfo.buildNumber}', - 'X-Device': _device, - 'X-Locale': Platform.localeName, - }, - body: identifier, - ); - - return switch (result) { - ApiSuccess(:final data) => data, - ApiFailure(:final statusCode) - when statusCode == HttpStatus.notFound => - throw const HttpException('Failed to login, identifier not found'), - ApiFailure(:final error) => throw http.ClientException( - error, - ), - }; - } - - Future login({final String? identifier}) async { - final http.Response response = await testLogin( - identifier ?? this.identifier!, - ); - final Map json = - jsonDecode(utf8.decode(response.bodyBytes)) as Map; - - if (identifier != null) { - this.identifier = identifier; - await SharedPreferencesController.instance.setString( - ConfigPropertyKey.identifier, - identifier, - ); - } - - member = MemberDto.fromJson(json); - streamController.add(member!); - _analytics.logLogin(); - - if (identifier != null) { - await refresh(); - } - } - - Future refresh({final bool isRetry = false}) async { - final ApiResult> result = await _client - .get>('/v1/members/refresh', token: member!.token); - - switch (result) { - case ApiFailure>(:final statusCode) - when statusCode == HttpStatus.unauthorized: - if (isRetry) { - debugPrint('Failed to refresh member data: Unauthorized after retry'); - return; - } - await login(); - return refresh(isRetry: true); - - case ApiSuccess>(:final data): - final RefreshMemberDto refreshedMember = RefreshMemberDto.fromJson( - data, - ); - - member = member!.copyWith( - totalDuration: refreshedMember.totalDuration, - totalUnseenDuration: refreshedMember.totalUnseenDuration, - ); - - final List missedAnimes = refreshedMember - .missedAnimes - .data - .map( - (final dynamic e) => - MissedAnimeDto.fromJson(e as Map), - ) - .toList(); - - final List followedAnimes = refreshedMember - .followedAnimes - .data - .map( - (final dynamic e) => AnimeDto.fromJson(e as Map), - ) - .toList(); - - final List followedEpisodes = refreshedMember - .followedEpisodes - .data - .map( - (final dynamic e) => - EpisodeMappingDto.fromJson(e as Map), - ) - .toList(); - - streamController.add(member!); - MissedAnimeController.instance.setItems(missedAnimes); - FollowedAnimeController.instance.setItems(followedAnimes); - FollowedEpisodeController.instance.setItems(followedEpisodes); - - case ApiFailure>(:final error): - debugPrint('Failed to refresh member data: $error'); - } - } - - Future changeImage(final BuildContext context) async { - final ImagePicker picker = ImagePicker(); - final XFile? result = await picker.pickImage(source: ImageSource.gallery); - final Uint8List? bytes = await result?.readAsBytes(); - - if (result == null || bytes == null || !context.mounted) { - return; - } - - const List allowedFormats = ['jpeg', 'png', 'jpg']; - - // Check if the image format is allowed - if (!allowedFormats.contains(result.path.split('.').last)) { - await showDialog( - context: context, - builder: (final BuildContext context) => AlertDialog( - title: Text(AppLocalizations.of(context)!.invalidImageFormat), - content: Text(AppLocalizations.of(context)!.invalidImageExtension), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(AppLocalizations.of(context)!.ok), - ), - ], - ), - ); - - return; - } - - final CropController controller = CropController(); - - await Navigator.of(context).push( - MaterialPageRoute( - builder: (final BuildContext context) => - CropView(bytes: bytes, controller: controller), - ), - ); - } + StreamController get streamController => + AuthViewModel.instance.streamController; + String? get identifier => AuthViewModel.instance.identifier; + set identifier(final String? value) => + AuthViewModel.instance.identifier = value; + MemberDto? get member => AuthViewModel.instance.member; + set member(final MemberDto? value) => AuthViewModel.instance.member = value; + bool get isImageUploadLoading => + MemberViewModel.instance.isImageUploadLoading; + set isImageUploadLoading(final bool value) => + MemberViewModel.instance.isImageUploadLoading = value; + + Future init({final bool afterDelete = false}) => + AuthViewModel.instance.init(afterDelete: afterDelete); + Future register() => AuthViewModel.instance.register(); + Future testLogin(final String identifier) => + AuthViewModel.instance.testLogin(identifier); + Future login({final String? identifier}) => + AuthViewModel.instance.login(identifier: identifier); + + Future refresh({final bool isRetry = false}) => + MemberViewModel.instance.refresh(isRetry: isRetry); + Future changeImage(final BuildContext context) => + MemberViewModel.instance.changeImage(context); Future updateImage( final Uint8List image, { final bool isRetry = false, - }) async { - isImageUploadLoading = true; - streamController.add(member!); - - final ApiResult result = await _client.postMultipart( - '/v1/members/image', - member!.token, - image, - ); - - switch (result) { - case ApiFailure(:final statusCode) - when statusCode == HttpStatus.unauthorized: - if (isRetry) { - throw Exception('Unauthorized after retry'); - } - await login(); - return updateImage(image, isRetry: true); - - case ApiSuccess(): - isImageUploadLoading = false; - member = member!.copyWith( - attachmentLastUpdateDateTime: DateTime.now().toIso8601String(), - ); - streamController.add(member!); - - case ApiFailure(:final error): - throw HttpException(error); - } - } + }) => MemberViewModel.instance.updateImage(image, isRetry: isRetry); Future associateEmail( final String email, { final bool isRetry = false, - }) async { - final ApiResult result = await _client.post( - '/v1/members/associate-email', - token: member!.token, - body: email, - ); - - switch (result) { - case ApiFailure(:final statusCode) - when statusCode == HttpStatus.unauthorized: - if (isRetry) { - throw Exception('Unauthorized after retry'); - } - await login(); - return associateEmail(email, isRetry: true); - - case ApiFailure(:final statusCode) - when statusCode == HttpStatus.conflict: - throw const ConflictEmailException(); - - case ApiSuccess(:final data): - return _extractUuid(data); - - case ApiFailure(:final error): - throw HttpException(error); - } - } - - String _extractUuid(final http.Response response) { - return (jsonDecode(utf8.decode(response.bodyBytes)) - as Map)['uuid'] - as String; - } - + }) => EmailViewModel.instance.associateEmail(email, isRetry: isRetry); Future forgotIdentifier( final String email, { final bool isRetry = false, - }) async { - final ApiResult result = await _client.post( - '/v1/members/forgot-identifier', - token: member!.token, - body: email, - ); - - switch (result) { - case ApiFailure(:final statusCode) - when statusCode == HttpStatus.unauthorized: - if (isRetry) { - throw Exception('Unauthorized after retry'); - } - await login(); - return forgotIdentifier(email, isRetry: true); - - case ApiFailure(:final statusCode) - when statusCode == HttpStatus.conflict: - throw const ConflictEmailException(); - - case ApiSuccess(:final data): - return _extractUuid(data); - - case ApiFailure(:final error): - throw HttpException(error); - } - } - + }) => EmailViewModel.instance.forgotIdentifier(email, isRetry: isRetry); Future validateAction( final String uuid, final String code, { final bool isRetry = false, - }) async { - final ApiResult result = await _client.post( - '/v1/member-actions/validate?uuid=$uuid', - token: member!.token, - body: code, - ); - - switch (result) { - case ApiFailure(:final statusCode) - when statusCode == HttpStatus.unauthorized: - if (isRetry) { - throw Exception('Unauthorized after retry'); - } - await login(); - return validateAction(uuid, code, isRetry: true); - - case ApiSuccess(): - return; - - case ApiFailure(:final error): - throw HttpException(error); - } - } + }) => EmailViewModel.instance.validateAction(uuid, code, isRetry: isRetry); Future followAnime( final String anime, { final bool loadMemberData = true, final bool isRetry = false, - }) async { - if (member!.followedAnimes.contains(anime)) { - return; - } - - final ApiResult result = await _client.put( - '/v1/members/animes', - member!.token, - jsonEncode({'uuid': anime}), - ); - - switch (result) { - case ApiFailure(:final statusCode) - when statusCode == HttpStatus.unauthorized: - if (isRetry) { - throw Exception('Unauthorized after retry'); - } - await login(); - return followAnime( - anime, - loadMemberData: loadMemberData, - isRetry: true, - ); - - case ApiSuccess(): - member!.followedAnimes.add(anime); - - if (loadMemberData) { - await refresh(); - } else { - streamController.add(member!); - } - - case ApiFailure(:final error): - throw HttpException(error); - } - } - + }) => FollowViewModel.instance.followAnime( + anime, + loadMemberData: loadMemberData, + isRetry: isRetry, + ); Future unfollowAnime( final String anime, { final bool isRetry = false, - }) async { - final ApiResult result = await _client.delete( - '/v1/members/animes', - member!.token, - jsonEncode({'uuid': anime}), - ); - - switch (result) { - case ApiFailure(:final statusCode) - when statusCode == HttpStatus.unauthorized: - if (isRetry) { - throw Exception('Unauthorized after retry'); - } - await login(); - return unfollowAnime(anime, isRetry: true); - - case ApiSuccess(): - member!.followedAnimes.remove(anime); - await refresh(); - - case ApiFailure(:final error): - throw HttpException(error); - } - } - + }) => FollowViewModel.instance.unfollowAnime(anime, isRetry: isRetry); Future followAllEpisodes( final String anime, { final bool isRetry = false, - }) async { - if (!member!.followedAnimes.contains(anime)) { - await followAnime(anime, loadMemberData: false); - } - - final ApiResult result = await _client.put( - '/v1/members/follow-all-episodes', - member!.token, - jsonEncode({'uuid': anime}), - ); - - switch (result) { - case ApiFailure(:final statusCode) - when statusCode == HttpStatus.unauthorized: - if (isRetry) { - throw Exception('Unauthorized after retry'); - } - await login(); - return followAllEpisodes(anime, isRetry: true); - - case ApiSuccess(:final data): - final Map json = - jsonDecode(utf8.decode(data.bodyBytes)) as Map; - final List newEpisodes = List.from( - json['data'] as List, - ); - member!.followedEpisodes.addAll(newEpisodes); - await refresh(); - - case ApiFailure(:final error): - throw HttpException(error); - } - } - + }) => FollowViewModel.instance.followAllEpisodes(anime, isRetry: isRetry); Future followEpisode( final String anime, final String episode, { final bool refreshAfterFollow = true, final bool isRetry = false, - }) async { - if (member!.followedEpisodes.contains(episode)) { - return; - } - - if (!member!.followedAnimes.contains(anime)) { - await followAnime(anime, loadMemberData: false); - } - - final ApiResult result = await _client.put( - '/v1/members/episodes', - member!.token, - jsonEncode({'uuid': episode}), - ); - - switch (result) { - case ApiFailure(:final statusCode) - when statusCode == HttpStatus.unauthorized: - if (isRetry) { - throw Exception('Unauthorized after retry'); - } - await login(); - return followEpisode( - anime, - episode, - refreshAfterFollow: refreshAfterFollow, - isRetry: true, - ); - - case ApiSuccess(): - member!.followedEpisodes.add(episode); - - if (refreshAfterFollow) { - await refresh(); - } - - case ApiFailure(:final error): - throw HttpException(error); - } - } - + }) => FollowViewModel.instance.followEpisode( + anime, + episode, + refreshAfterFollow: refreshAfterFollow, + isRetry: isRetry, + ); Future unfollowEpisode( final String episode, { final bool isRetry = false, - }) async { - final ApiResult result = await _client.delete( - '/v1/members/episodes', - member!.token, - jsonEncode({'uuid': episode}), - ); - - switch (result) { - case ApiFailure(:final statusCode) - when statusCode == HttpStatus.unauthorized: - if (isRetry) { - throw Exception('Unauthorized after retry'); - } - await login(); - return unfollowEpisode(episode, isRetry: true); - - case ApiSuccess(): - member!.followedEpisodes.remove(episode); - await refresh(); - - case ApiFailure(:final error): - throw HttpException(error); - } - } -} - -class ConflictEmailException implements Exception { - const ConflictEmailException(); + }) => FollowViewModel.instance.unfollowEpisode(episode, isRetry: isRetry); } diff --git a/lib/ui/viewmodels/member_viewmodel.dart b/lib/ui/viewmodels/member_viewmodel.dart new file mode 100644 index 0000000..d5f54f6 --- /dev/null +++ b/lib/ui/viewmodels/member_viewmodel.dart @@ -0,0 +1,153 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:application/core/network/api_client.dart'; +import 'package:application/core/network/api_result.dart'; +import 'package:application/data/models/anime_dto.dart'; +import 'package:application/data/models/episode_mapping_dto.dart'; +import 'package:application/data/models/missed_anime_dto.dart'; +import 'package:application/data/models/refresh_member_dto.dart'; +import 'package:application/ui/viewmodels/animes/followed_anime_controller.dart'; +import 'package:application/ui/viewmodels/animes/missed_anime_controller.dart'; +import 'package:application/l10n/app_localizations.dart'; +import 'package:application/ui/viewmodels/auth_viewmodel.dart'; +import 'package:application/ui/viewmodels/episodes/followed_episode_controller.dart'; +import 'package:application/ui/views/crop_view.dart'; +import 'package:crop_your_image/crop_your_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; + +/// Holds the member state and handles refresh, image upload, and image +/// picking operations. +class MemberViewModel { + MemberViewModel({ApiClient? client}) : _client = client ?? const ApiClient(); + + static final MemberViewModel instance = MemberViewModel(); + final ApiClient _client; + bool isImageUploadLoading = false; + + /// Refreshes member data from the server. + Future refresh({final bool isRetry = false}) async { + final auth = AuthViewModel.instance; + final result = await _client.get>( + '/v1/members/refresh', + token: auth.member!.token, + ); + + switch (result) { + case ApiFailure>(:final statusCode) + when statusCode == HttpStatus.unauthorized: + if (isRetry) { + debugPrint('Failed to refresh member data: Unauthorized after retry'); + return; + } + await auth.login(); + return refresh(isRetry: true); + + case ApiSuccess>(:final data): + final refreshedMember = RefreshMemberDto.fromJson(data); + auth.member = auth.member!.copyWith( + totalDuration: refreshedMember.totalDuration, + totalUnseenDuration: refreshedMember.totalUnseenDuration, + ); + final missedAnimes = refreshedMember.missedAnimes.data + .map( + (final e) => MissedAnimeDto.fromJson(e as Map), + ) + .toList(); + final followedAnimes = refreshedMember.followedAnimes.data + .map((final e) => AnimeDto.fromJson(e as Map)) + .toList(); + final followedEpisodes = refreshedMember.followedEpisodes.data + .map( + (final e) => + EpisodeMappingDto.fromJson(e as Map), + ) + .toList(); + + auth.streamController.add(auth.member!); + MissedAnimeController.instance.setItems(missedAnimes); + FollowedAnimeController.instance.setItems(followedAnimes); + FollowedEpisodeController.instance.setItems(followedEpisodes); + + case ApiFailure>(:final error): + debugPrint('Failed to refresh member data: $error'); + } + } + + /// Opens the gallery to pick an image, validates format, and navigates + /// to [CropView] for cropping. + Future changeImage(final BuildContext context) async { + final picker = ImagePicker(); + final result = await picker.pickImage(source: ImageSource.gallery); + final bytes = await result?.readAsBytes(); + + if (result == null || bytes == null || !context.mounted) { + return; + } + + const allowedFormats = ['jpeg', 'png', 'jpg']; + if (!allowedFormats.contains(result.path.split('.').last)) { + await showDialog( + context: context, + builder: (final context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.invalidImageFormat), + content: Text(AppLocalizations.of(context)!.invalidImageExtension), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.ok), + ), + ], + ), + ); + return; + } + + final controller = CropController(); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => + CropView(bytes: bytes, controller: controller), + ), + ); + } + + /// Uploads the cropped image bytes to the server. + Future updateImage( + final Uint8List image, { + final bool isRetry = false, + }) async { + final auth = AuthViewModel.instance; + isImageUploadLoading = true; + auth.streamController.add(auth.member!); + + final result = await _client.postMultipart( + '/v1/members/image', + auth.member!.token, + image, + ); + + switch (result) { + case ApiFailure(:final statusCode) + when statusCode == HttpStatus.unauthorized: + if (isRetry) { + throw Exception('Unauthorized after retry'); + } + await auth.login(); + return updateImage(image, isRetry: true); + + case ApiSuccess(): + isImageUploadLoading = false; + auth.member = auth.member!.copyWith( + attachmentLastUpdateDateTime: DateTime.now().toIso8601String(), + ); + auth.streamController.add(auth.member!); + + case ApiFailure(:final error): + throw HttpException(error); + } + } +} diff --git a/test/ui/viewmodels/member_controller_test.dart b/test/ui/viewmodels/member_controller_test.dart index 48b56c4..6ddb754 100644 --- a/test/ui/viewmodels/member_controller_test.dart +++ b/test/ui/viewmodels/member_controller_test.dart @@ -4,7 +4,7 @@ import 'package:application/core/analytics/analytics.dart'; import 'package:application/core/network/api_client.dart'; import 'package:application/core/network/api_result.dart'; import 'package:application/data/models/enums/config_property_key.dart'; -import 'package:application/ui/viewmodels/member_controller.dart'; +import 'package:application/ui/viewmodels/auth_viewmodel.dart'; import 'package:application/ui/viewmodels/shared_preferences_controller.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -68,7 +68,7 @@ void main() { return const ApiFailure('Not found', 404); }, ); - final controller = MemberController( + final controller = AuthViewModel( client: fakeClient, analytics: const Analytics(), ); @@ -88,7 +88,7 @@ void main() { return const ApiFailure('Member not found', 404); }, ); - final controller = MemberController( + final controller = AuthViewModel( client: fakeClient, analytics: const Analytics(), ); @@ -116,7 +116,7 @@ void main() { ); }, ); - final controller = MemberController( + final controller = AuthViewModel( client: fakeClient, analytics: const Analytics(), ); @@ -170,7 +170,7 @@ void main() { }, ); - final controller = MemberController( + final controller = AuthViewModel( client: fakeClient, analytics: const Analytics(), );