feat(account): 增加账号中心页面逻辑
This commit is contained in:
1 parent
ef7f4ce3b6
commit
c68f02449f
6 files changed
+503
No files matched your search
@@ -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<void> Function() clearSession;
|
||||||
|
DateTime? scheduledAt;
|
||||||
|
bool requesting = false;
|
||||||
|
bool cancelling = false;
|
||||||
|
String? error;
|
||||||
|
String? actionMessage;
|
||||||
|
bool cancelSucceeded = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// 申请注销;成功后立即清理本地会话。
|
||||||
|
Future<bool> 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<bool> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AccountDevice> devices = const [];
|
||||||
|
bool loading = false;
|
||||||
|
String? error;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// 拉取当前账号的设备列表。
|
||||||
|
Future<void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> Function(String text) copyText;
|
||||||
|
AccountExport? data;
|
||||||
|
bool loading = false;
|
||||||
|
String? error;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// 加载完整导出数据。
|
||||||
|
Future<void> 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<bool> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> Function(AccountProfile profile) onProfileUpdated;
|
||||||
|
|
||||||
|
AccountProfile? profile;
|
||||||
|
bool loading = false;
|
||||||
|
bool saving = false;
|
||||||
|
String? error;
|
||||||
|
String? fieldError;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// 进入页面时加载当前资料。
|
||||||
|
Future<void> load() async {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
profile = await _repository.profile();
|
||||||
|
} catch (failure) {
|
||||||
|
error = _message(failure) ?? 'accountProfileLoadFailed';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只提交本次改动;成功后同步登录态摘要。
|
||||||
|
Future<bool> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '没有待处理的注销申请');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<AccountDevice> 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<AccountDevice> _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<AccountProfile> profile() async {
|
||||||
|
profileCalls++;
|
||||||
|
if (failProfile) throw const ApiException(500, '资料加载失败');
|
||||||
|
return _profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AccountProfile> 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<List<AccountDevice>> devices() async {
|
||||||
|
deviceCalls++;
|
||||||
|
if (failDevices) throw const ApiException(500, '设备加载失败');
|
||||||
|
return [..._devices];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AccountExport> export() async {
|
||||||
|
exportCalls++;
|
||||||
|
if (failExport) throw const ApiException(500, '导出失败');
|
||||||
|
return _export;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DateTime?> deactivate(String reason) async {
|
||||||
|
lastDeactivateReason = reason;
|
||||||
|
if (failDeactivate) throw const ApiException(100009, '注销申请已在处理中');
|
||||||
|
return DateTime(2026, 11, 3, 9, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelDeactivation() async {
|
||||||
|
if (failCancel) {
|
||||||
|
throw const ApiException(100009, '没有待处理的注销申请');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user