diff --git a/lib/pages/account/account_deactivate_logic.dart b/lib/pages/account/account_deactivate_logic.dart new file mode 100644 index 0000000..bcb9d7e --- /dev/null +++ b/lib/pages/account/account_deactivate_logic.dart @@ -0,0 +1,85 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../../core/data/remote/api_client.dart'; +import '../../core/data/repositories/account_repository.dart'; + +/// 注销申请与撤销状态。 +class AccountDeactivateLogic extends ChangeNotifier { + AccountDeactivateLogic( + this._repository, { + required this.clearSession, + }); + + static const maxReasonLength = 200; + + final AccountRepository _repository; + final Future Function() clearSession; + DateTime? scheduledAt; + bool requesting = false; + bool cancelling = false; + String? error; + String? actionMessage; + bool cancelSucceeded = false; + bool _disposed = false; + + /// 申请注销;成功后立即清理本地会话。 + Future request(String reason) async { + requesting = true; + error = null; + cancelSucceeded = false; + _notify(); + try { + scheduledAt = await _repository.deactivate( + reason.trim().substring( + 0, + reason.trim().length > maxReasonLength + ? maxReasonLength + : reason.trim().length, + ), + ); + await clearSession(); + return true; + } catch (failure) { + error = failure is ApiException + ? failure.message + : 'accountDeactivateFailed'; + return false; + } finally { + requesting = false; + _notify(); + } + } + + /// 撤销待处理申请;失败时保留后端文案。 + Future cancel() async { + cancelling = true; + actionMessage = null; + cancelSucceeded = false; + _notify(); + try { + await _repository.cancelDeactivation(); + cancelSucceeded = true; + return true; + } catch (failure) { + actionMessage = failure is ApiException + ? failure.message + : 'accountCancelFailed'; + return false; + } finally { + cancelling = false; + _notify(); + } + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/pages/account/account_devices_logic.dart b/lib/pages/account/account_devices_logic.dart new file mode 100644 index 0000000..cbc1241 --- /dev/null +++ b/lib/pages/account/account_devices_logic.dart @@ -0,0 +1,45 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../../core/data/models/account_device.dart'; +import '../../core/data/remote/api_client.dart'; +import '../../core/data/repositories/account_repository.dart'; + +/// 只读登录设备列表状态。 +class AccountDevicesLogic extends ChangeNotifier { + AccountDevicesLogic(this._repository); + + final AccountRepository _repository; + List devices = const []; + bool loading = false; + String? error; + bool _disposed = false; + + /// 拉取当前账号的设备列表。 + Future load() async { + loading = true; + error = null; + _notify(); + try { + devices = await _repository.devices(); + } catch (failure) { + error = failure is ApiException + ? failure.message + : 'accountDevicesLoadFailed'; + } finally { + loading = false; + _notify(); + } + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/pages/account/account_export_logic.dart b/lib/pages/account/account_export_logic.dart new file mode 100644 index 0000000..90546a0 --- /dev/null +++ b/lib/pages/account/account_export_logic.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../../core/data/models/account_export.dart'; +import '../../core/data/remote/api_client.dart'; +import '../../core/data/repositories/account_repository.dart'; + +/// 账号数据导出状态与复制动作。 +class AccountExportLogic extends ChangeNotifier { + AccountExportLogic(this._repository, {required this.copyText}); + + final AccountRepository _repository; + final Future Function(String text) copyText; + AccountExport? data; + bool loading = false; + String? error; + bool _disposed = false; + + /// 加载完整导出数据。 + Future load() async { + loading = true; + error = null; + _notify(); + try { + data = await _repository.export(); + } catch (failure) { + error = failure is ApiException + ? failure.message + : 'accountExportLoadFailed'; + } finally { + loading = false; + _notify(); + } + } + + /// 把后端下发的完整 JSON 写入剪贴板。 + Future copy() async { + final current = data; + if (current == null) return false; + await copyText(current.rawJson); + return true; + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/pages/account/account_profile_logic.dart b/lib/pages/account/account_profile_logic.dart new file mode 100644 index 0000000..84292cc --- /dev/null +++ b/lib/pages/account/account_profile_logic.dart @@ -0,0 +1,91 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../../core/data/models/account_profile.dart'; +import '../../core/data/remote/api_client.dart'; +import '../../core/data/repositories/account_repository.dart'; + +/// 个人资料页状态与保存动作。 +class AccountProfileLogic extends ChangeNotifier { + AccountProfileLogic(this._repository, {required this.onProfileUpdated}); + + static const maxNicknameLength = 30; + + final AccountRepository _repository; + final Future Function(AccountProfile profile) onProfileUpdated; + + AccountProfile? profile; + bool loading = false; + bool saving = false; + String? error; + String? fieldError; + bool _disposed = false; + + /// 进入页面时加载当前资料。 + Future load() async { + loading = true; + error = null; + _notify(); + try { + profile = await _repository.profile(); + } catch (failure) { + error = _message(failure) ?? 'accountProfileLoadFailed'; + } finally { + loading = false; + _notify(); + } + } + + /// 只提交本次改动;成功后同步登录态摘要。 + Future save({ + required String nickname, + required String gender, + String? birthday, + }) async { + final current = profile; + if (current == null) return false; + final trimmed = nickname.trim(); + if (trimmed.isEmpty || trimmed.length > maxNicknameLength) { + fieldError = 'accountNicknameInvalid'; + _notify(); + return false; + } + final update = AccountProfileUpdate( + nickname: trimmed == current.nickname ? null : trimmed, + gender: gender.isEmpty || gender == current.gender ? null : gender, + birthday: birthday == null || birthday.isEmpty ? null : birthday, + ); + if (update.toJson().isEmpty) return true; + + saving = true; + error = null; + fieldError = null; + _notify(); + try { + final next = await _repository.updateProfile(update); + profile = next; + await onProfileUpdated(next); + return true; + } catch (failure) { + error = _message(failure) ?? 'accountProfileSaveFailed'; + return false; + } finally { + saving = false; + _notify(); + } + } + + String? _message(Object failure) => + failure is ApiException ? failure.message : null; + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/test/account/account_logic_test.dart b/test/account/account_logic_test.dart new file mode 100644 index 0000000..47757a6 --- /dev/null +++ b/test/account/account_logic_test.dart @@ -0,0 +1,101 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/account_device.dart'; +import 'package:fruitshorts/core/data/models/account_profile.dart'; +import 'package:fruitshorts/pages/account/account_deactivate_logic.dart'; +import 'package:fruitshorts/pages/account/account_devices_logic.dart'; +import 'package:fruitshorts/pages/account/account_export_logic.dart'; +import 'package:fruitshorts/pages/account/account_profile_logic.dart'; + +import '../support/fake_account_repository.dart'; + +void main() { + test('should_update_profile_and_notify_auth_when_saved', () async { + final repository = FakeAccountRepository(); + AccountProfile? applied; + final logic = AccountProfileLogic( + repository, + onProfileUpdated: (profile) async => applied = profile, + ); + await logic.load(); + + final ok = await logic.save( + nickname: ' Nadia Baru ', + gender: 'unknown', + birthday: '1999-01-02', + ); + + expect(ok, isTrue); + expect(repository.lastUpdate?.nickname, 'Nadia Baru'); + expect(repository.lastUpdate?.gender, 'unknown'); + expect(repository.lastUpdate?.birthday, '1999-01-02'); + expect(applied?.nickname, 'Nadia Baru'); + }); + + test('should_load_devices_and_keep_the_page_read_only', () async { + final repository = FakeAccountRepository( + devices: const [ + AccountDevice( + id: '1', + deviceId: 'd-device-1234', + platform: 'IOS', + deviceModel: 'iPhone 15', + status: 'active', + ), + ], + ); + final logic = AccountDevicesLogic(repository); + + await logic.load(); + + expect(logic.devices.single.deviceModel, 'iPhone 15'); + expect(logic.loading, isFalse); + expect(logic.error, isNull); + }); + + test('should_copy_complete_export_json_when_requested', () async { + final repository = FakeAccountRepository(); + String copied = ''; + final logic = AccountExportLogic( + repository, + copyText: (value) async => copied = value, + ); + await logic.load(); + + final ok = await logic.copy(); + + expect(ok, isTrue); + expect(copied, contains('"credentials"')); + expect(copied, contains('n***@example.com')); + }); + + test('should_clear_session_and_keep_schedule_when_deactivation_succeeds', () async { + final repository = FakeAccountRepository(); + var cleared = false; + final logic = AccountDeactivateLogic( + repository, + clearSession: () async => cleared = true, + ); + + final ok = await logic.request('不再使用'); + + expect(ok, isTrue); + expect(repository.lastDeactivateReason, '不再使用'); + expect(logic.scheduledAt, DateTime(2026, 11, 3, 9, 30)); + expect(cleared, isTrue); + }); + + test('should_show_backend_message_when_cancel_deactivation_fails', () async { + final repository = FakeAccountRepository()..failCancel = true; + final logic = AccountDeactivateLogic( + repository, + clearSession: () async {}, + ); + + final ok = await logic.cancel(); + + expect(ok, isFalse); + expect(logic.actionMessage, '没有待处理的注销申请'); + }); +} diff --git a/test/support/fake_account_repository.dart b/test/support/fake_account_repository.dart new file mode 100644 index 0000000..d23f6e6 --- /dev/null +++ b/test/support/fake_account_repository.dart @@ -0,0 +1,127 @@ +// Author: fruit.com.cn + +import 'package:fruitshorts/core/data/models/account_device.dart'; +import 'package:fruitshorts/core/data/models/account_export.dart'; +import 'package:fruitshorts/core/data/models/account_profile.dart'; +import 'package:fruitshorts/core/data/repositories/account_repository.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; + +/// 账号中心测试替身;可预置数据与失败文案。 +class FakeAccountRepository implements AccountRepository { + FakeAccountRepository({ + AccountProfile? profile, + List devices = const [], + AccountExport? export, + }) : _profile = profile ?? defaultProfile, + _devices = [...devices], + _export = export ?? defaultExport; + + static const defaultProfile = AccountProfile( + uid: 'U-K19', + nickname: 'Nadia', + avatarUrl: '', + gender: 'female', + countryCode: 'ID', + regionCode: 'JK', + language: 'id', + timezone: 'Asia/Jakarta', + currency: 'IDR', + status: 'normal', + maskedTarget: 'n***@example.com', + vipLevel: 0, + ); + + static const defaultExport = AccountExport( + profile: defaultProfile, + credentials: [ + AccountCredential( + type: 'EMAIL', + masked: 'n***@example.com', + verified: true, + ), + ], + consentCount: 1, + devices: [], + raw: { + 'profile': {'uid': 'U-K19', 'nickname': 'Nadia'}, + 'credentials': [ + {'type': 'EMAIL', 'masked': 'n***@example.com', 'verified': true}, + ], + 'consents': [ + {'scene': 'privacy'}, + ], + 'devices': [], + }, + ); + + AccountProfile _profile; + List _devices; + AccountExport _export; + AccountProfileUpdate? lastUpdate; + String? lastDeactivateReason; + int profileCalls = 0; + int deviceCalls = 0; + int exportCalls = 0; + bool failProfile = false; + bool failDevices = false; + bool failExport = false; + bool failUpdate = false; + bool failDeactivate = false; + bool failCancel = false; + + @override + Future profile() async { + profileCalls++; + if (failProfile) throw const ApiException(500, '资料加载失败'); + return _profile; + } + + @override + Future updateProfile(AccountProfileUpdate update) async { + lastUpdate = update; + if (failUpdate) throw const ApiException(400, '昵称不可用'); + _profile = AccountProfile( + uid: _profile.uid, + nickname: update.nickname ?? _profile.nickname, + avatarUrl: _profile.avatarUrl, + gender: update.gender ?? _profile.gender, + countryCode: _profile.countryCode, + regionCode: _profile.regionCode, + language: _profile.language, + timezone: _profile.timezone, + currency: _profile.currency, + status: _profile.status, + maskedTarget: _profile.maskedTarget, + vipLevel: _profile.vipLevel, + ); + return _profile; + } + + @override + Future> devices() async { + deviceCalls++; + if (failDevices) throw const ApiException(500, '设备加载失败'); + return [..._devices]; + } + + @override + Future export() async { + exportCalls++; + if (failExport) throw const ApiException(500, '导出失败'); + return _export; + } + + @override + Future deactivate(String reason) async { + lastDeactivateReason = reason; + if (failDeactivate) throw const ApiException(100009, '注销申请已在处理中'); + return DateTime(2026, 11, 3, 9, 30); + } + + @override + Future cancelDeactivation() async { + if (failCancel) { + throw const ApiException(100009, '没有待处理的注销申请'); + } + } +}