feat(account): 合并 K19 账号中心
This commit is contained in:
commit
9b3ab9d5de
31 files changed
+3492
-12
No files matched your search
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'core/config/locale_controller.dart';
|
||||
import 'core/data/remote/account_api.dart';
|
||||
import 'core/data/remote/api_client.dart';
|
||||
import 'core/data/remote/auth_api.dart';
|
||||
import 'core/data/remote/catalog_api.dart';
|
||||
@@ -18,6 +19,7 @@ import 'core/data/remote/notice_api.dart';
|
||||
import 'core/data/remote/playback_api.dart';
|
||||
import 'core/data/remote/watch_api.dart';
|
||||
import 'core/data/repositories/catalog_repository.dart';
|
||||
import 'core/data/repositories/account_repository.dart';
|
||||
import 'core/data/repositories/category_repository.dart';
|
||||
import 'core/data/repositories/comment_repository.dart';
|
||||
import 'core/data/repositories/download_repository.dart';
|
||||
@@ -53,6 +55,7 @@ class LumaDramaApp extends StatefulWidget {
|
||||
this.commentRepository,
|
||||
this.interactionRepository,
|
||||
this.noticeRepository,
|
||||
this.accountRepository,
|
||||
this.haptics = const FlutterHaptics(),
|
||||
});
|
||||
|
||||
@@ -68,6 +71,7 @@ class LumaDramaApp extends StatefulWidget {
|
||||
final CommentRepository? commentRepository;
|
||||
final InteractionRepository? interactionRepository;
|
||||
final NoticeRepository? noticeRepository;
|
||||
final AccountRepository? accountRepository;
|
||||
final Haptics haptics;
|
||||
|
||||
@override
|
||||
@@ -138,6 +142,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
late final NoticeRepository _noticeRepository =
|
||||
widget.noticeRepository ?? HttpNoticeRepository(NoticeApi(_apiClient));
|
||||
|
||||
late final AccountRepository _accountRepository =
|
||||
widget.accountRepository ?? HttpAccountRepository(AccountApi(_apiClient));
|
||||
|
||||
late final InteractionController _interactionController =
|
||||
InteractionController(_interactionRepository);
|
||||
|
||||
@@ -188,6 +195,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
Provider<CommentRepository>.value(value: _commentRepository),
|
||||
Provider<InteractionRepository>.value(value: _interactionRepository),
|
||||
Provider<NoticeRepository>.value(value: _noticeRepository),
|
||||
Provider<AccountRepository>.value(value: _accountRepository),
|
||||
Provider<Haptics>.value(value: widget.haptics),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => CatalogController(_catalogRepository)..load(),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 登录设备列表项;只保留页面展示需要的字段。
|
||||
class AccountDevice {
|
||||
const AccountDevice({
|
||||
required this.id,
|
||||
required this.deviceId,
|
||||
required this.platform,
|
||||
required this.deviceModel,
|
||||
required this.status,
|
||||
this.firstSeenAt,
|
||||
this.lastSeenAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String deviceId;
|
||||
final String platform;
|
||||
final String deviceModel;
|
||||
final String status;
|
||||
final DateTime? firstSeenAt;
|
||||
final DateTime? lastSeenAt;
|
||||
|
||||
/// 兼容 Long 字符串与数字;userId/deleted 不解析、不展示。
|
||||
factory AccountDevice.fromJson(Map<String, dynamic> json) {
|
||||
return AccountDevice(
|
||||
id: jsonToId(json['id']),
|
||||
deviceId: json['deviceId']?.toString() ?? '',
|
||||
platform: json['platform']?.toString() ?? '',
|
||||
deviceModel: json['deviceModel']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? '',
|
||||
firstSeenAt: _date(json['firstSeenAt']),
|
||||
lastSeenAt: _date(json['lastSeenAt']),
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? _date(dynamic value) =>
|
||||
DateTime.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'account_device.dart';
|
||||
import 'account_profile.dart';
|
||||
|
||||
/// 导出中的登录凭据摘要。
|
||||
class AccountCredential {
|
||||
const AccountCredential({
|
||||
required this.type,
|
||||
required this.masked,
|
||||
required this.verified,
|
||||
});
|
||||
|
||||
final String type;
|
||||
final String masked;
|
||||
final bool verified;
|
||||
|
||||
factory AccountCredential.fromJson(Map<String, dynamic> json) {
|
||||
return AccountCredential(
|
||||
type: json['type']?.toString() ?? '',
|
||||
masked: json['masked']?.toString() ?? '',
|
||||
verified: json['verified'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号导出数据;`rawJson` 用于复制后端下发的完整内容。
|
||||
class AccountExport {
|
||||
const AccountExport({
|
||||
required this.profile,
|
||||
required this.credentials,
|
||||
required this.consentCount,
|
||||
required this.devices,
|
||||
required this.raw,
|
||||
});
|
||||
|
||||
final AccountProfile profile;
|
||||
final List<AccountCredential> credentials;
|
||||
final int consentCount;
|
||||
final List<AccountDevice> devices;
|
||||
final Map<String, dynamic> raw;
|
||||
|
||||
int get deviceCount => devices.length;
|
||||
String get rawJson => const JsonEncoder.withIndent(' ').convert(raw);
|
||||
|
||||
factory AccountExport.fromJson(Map<String, dynamic> json) {
|
||||
final profile = _map(json['profile']);
|
||||
final credentials = _list(json['credentials']);
|
||||
final consents = _list(json['consents']);
|
||||
final devices = _list(json['devices']);
|
||||
return AccountExport(
|
||||
profile: AccountProfile.fromJson(profile),
|
||||
credentials: [
|
||||
for (final item in credentials) AccountCredential.fromJson(item),
|
||||
],
|
||||
consentCount: consents.length,
|
||||
devices: [for (final item in devices) AccountDevice.fromJson(item)],
|
||||
raw: json,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _map(dynamic value) =>
|
||||
value is Map ? value.cast<String, dynamic>() : const {};
|
||||
|
||||
static List<Map<String, dynamic>> _list(dynamic value) {
|
||||
if (value is! List) return const [];
|
||||
return [
|
||||
for (final item in value)
|
||||
if (item is Map) item.cast<String, dynamic>(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'auth_session.dart';
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 当前账号资料;字段与 `GET/PUT /app/me` 的实际响应一致。
|
||||
class AccountProfile {
|
||||
const AccountProfile({
|
||||
required this.uid,
|
||||
required this.nickname,
|
||||
required this.avatarUrl,
|
||||
required this.gender,
|
||||
required this.countryCode,
|
||||
required this.regionCode,
|
||||
required this.language,
|
||||
required this.timezone,
|
||||
required this.currency,
|
||||
required this.status,
|
||||
required this.maskedTarget,
|
||||
required this.vipLevel,
|
||||
this.createdAt,
|
||||
this.lastLoginAt,
|
||||
this.vipExpireAt,
|
||||
});
|
||||
|
||||
final String uid;
|
||||
final String nickname;
|
||||
final String avatarUrl;
|
||||
final String gender;
|
||||
final String countryCode;
|
||||
final String regionCode;
|
||||
final String language;
|
||||
final String timezone;
|
||||
final String currency;
|
||||
final String status;
|
||||
final String maskedTarget;
|
||||
final int vipLevel;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? lastLoginAt;
|
||||
final DateTime? vipExpireAt;
|
||||
|
||||
/// 解析资料响应;缺失字段按空值处理。
|
||||
factory AccountProfile.fromJson(Map<String, dynamic> json) {
|
||||
return AccountProfile(
|
||||
uid: jsonToId(json['uid']),
|
||||
nickname: json['nickname']?.toString() ?? '',
|
||||
avatarUrl: json['avatarUrl']?.toString() ?? '',
|
||||
gender: json['gender']?.toString() ?? '',
|
||||
countryCode: json['countryCode']?.toString() ?? '',
|
||||
regionCode: json['regionCode']?.toString() ?? '',
|
||||
language: json['language']?.toString() ?? '',
|
||||
timezone: json['timezone']?.toString() ?? '',
|
||||
currency: json['currency']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? '',
|
||||
maskedTarget: json['maskedTarget']?.toString() ?? '',
|
||||
vipLevel: jsonToInt(json['vipLevel']),
|
||||
createdAt: _date(json['createdAt']),
|
||||
lastLoginAt: _date(json['lastLoginAt']),
|
||||
vipExpireAt: _date(json['vipExpireAt']),
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? _date(dynamic value) =>
|
||||
DateTime.tryParse(value?.toString() ?? '');
|
||||
|
||||
/// 转为本地登录态摘要;只同步登录态实际使用的三项。
|
||||
AuthUser toAuthUser() =>
|
||||
AuthUser(uid: uid, nickname: nickname, avatarUrl: avatarUrl);
|
||||
}
|
||||
|
||||
/// 资料更新请求;null 字段不会进入请求体。
|
||||
class AccountProfileUpdate {
|
||||
const AccountProfileUpdate({
|
||||
this.nickname,
|
||||
this.avatarUrl,
|
||||
this.gender,
|
||||
this.birthday,
|
||||
this.language,
|
||||
this.timezone,
|
||||
this.regionCode,
|
||||
});
|
||||
|
||||
final String? nickname;
|
||||
final String? avatarUrl;
|
||||
final String? gender;
|
||||
final String? birthday;
|
||||
final String? language;
|
||||
final String? timezone;
|
||||
final String? regionCode;
|
||||
|
||||
/// 只发送本次有值的字段。
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (nickname != null) 'nickname': nickname,
|
||||
if (avatarUrl != null) 'avatarUrl': avatarUrl,
|
||||
if (gender != null) 'gender': gender,
|
||||
if (birthday != null) 'birthday': birthday,
|
||||
if (language != null) 'language': language,
|
||||
if (timezone != null) 'timezone': timezone,
|
||||
if (regionCode != null) 'regionCode': regionCode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/account_device.dart';
|
||||
import '../models/account_export.dart';
|
||||
import '../models/account_profile.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 个人中心接口:`/app/me*`。
|
||||
class AccountApi {
|
||||
const AccountApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<AccountProfile> profile() async {
|
||||
final data = await _client.get('/app/me');
|
||||
return AccountProfile.fromJson(_map(data));
|
||||
}
|
||||
|
||||
Future<AccountProfile> updateProfile(AccountProfileUpdate update) async {
|
||||
final data = await _client.put('/app/me', body: update.toJson());
|
||||
return AccountProfile.fromJson(_map(data));
|
||||
}
|
||||
|
||||
Future<List<AccountDevice>> devices() async {
|
||||
final data = await _client.get('/app/me/devices');
|
||||
if (data is! List) return const [];
|
||||
return [
|
||||
for (final item in data)
|
||||
if (item is Map) AccountDevice.fromJson(item.cast<String, dynamic>()),
|
||||
];
|
||||
}
|
||||
|
||||
Future<AccountExport> export() async {
|
||||
final data = await _client.get('/app/me/export');
|
||||
return AccountExport.fromJson(_map(data));
|
||||
}
|
||||
|
||||
Future<DateTime?> deactivate({String reason = ''}) async {
|
||||
final data = await _client.post(
|
||||
'/app/me/deactivate',
|
||||
body: {'reason': reason.trim()},
|
||||
);
|
||||
return DateTime.tryParse(data?.toString() ?? '');
|
||||
}
|
||||
|
||||
Future<void> cancelDeactivation() async {
|
||||
await _client.post('/app/me/deactivate/cancel');
|
||||
}
|
||||
|
||||
Map<String, dynamic> _map(Object? data) =>
|
||||
data is Map ? data.cast<String, dynamic>() : const {};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/account_device.dart';
|
||||
import '../models/account_export.dart';
|
||||
import '../models/account_profile.dart';
|
||||
import '../remote/account_api.dart';
|
||||
|
||||
/// 账号中心的数据来源。
|
||||
abstract interface class AccountRepository {
|
||||
Future<AccountProfile> profile();
|
||||
|
||||
Future<AccountProfile> updateProfile(AccountProfileUpdate update);
|
||||
|
||||
Future<List<AccountDevice>> devices();
|
||||
|
||||
Future<AccountExport> export();
|
||||
|
||||
Future<DateTime?> deactivate(String reason);
|
||||
|
||||
Future<void> cancelDeactivation();
|
||||
}
|
||||
|
||||
/// HTTP 实现。
|
||||
class HttpAccountRepository implements AccountRepository {
|
||||
const HttpAccountRepository(this._api);
|
||||
|
||||
final AccountApi _api;
|
||||
|
||||
@override
|
||||
Future<AccountProfile> profile() => _api.profile();
|
||||
|
||||
@override
|
||||
Future<AccountProfile> updateProfile(AccountProfileUpdate update) =>
|
||||
_api.updateProfile(update);
|
||||
|
||||
@override
|
||||
Future<List<AccountDevice>> devices() => _api.devices();
|
||||
|
||||
@override
|
||||
Future<AccountExport> export() => _api.export();
|
||||
|
||||
@override
|
||||
Future<DateTime?> deactivate(String reason) =>
|
||||
_api.deactivate(reason: reason);
|
||||
|
||||
@override
|
||||
Future<void> cancelDeactivation() => _api.cancelDeactivation();
|
||||
}
|
||||
@@ -34,7 +34,10 @@ class AuthController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> register({required String email, required String password}) async {
|
||||
Future<void> register({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
final session = await _api.register(
|
||||
email: email,
|
||||
password: password,
|
||||
@@ -90,6 +93,20 @@ class AuthController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 资料保存成功后同步登录态摘要,保留当前令牌。
|
||||
Future<void> applyProfile(AuthUser user) async {
|
||||
final current = _session;
|
||||
if (current == null) return;
|
||||
await _apply(
|
||||
AuthSession(
|
||||
accessToken: current.accessToken,
|
||||
refreshToken: current.refreshToken,
|
||||
expiresIn: current.expiresIn,
|
||||
user: user,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _apply(AuthSession session) async {
|
||||
_session = session;
|
||||
await _store.save(session);
|
||||
|
||||
+62
-1
@@ -386,5 +386,66 @@
|
||||
"position": {},
|
||||
"total": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"accountCenter": "Account",
|
||||
"accountProfile": "Profile",
|
||||
"accountDevices": "Login devices",
|
||||
"accountDataExport": "Data export",
|
||||
"accountDeactivate": "Delete account",
|
||||
"accountProfileLoadFailed": "Could not load your profile.",
|
||||
"accountProfileSaveFailed": "Could not save your profile.",
|
||||
"accountNickname": "Nickname",
|
||||
"accountNicknameInvalid": "Enter 1 to 30 characters.",
|
||||
"accountGender": "Gender",
|
||||
"accountMale": "Male",
|
||||
"accountFemale": "Female",
|
||||
"accountUnknownGender": "Prefer not to say",
|
||||
"accountSelectBirthday": "Set birthday",
|
||||
"accountBirthdayHint": "Birthday can be set but not cleared in this version.",
|
||||
"accountSaved": "Profile saved.",
|
||||
"accountReadOnlyInfo": "Account information",
|
||||
"accountUid": "UID",
|
||||
"accountMaskedAccount": "Account",
|
||||
"accountCountry": "Country",
|
||||
"accountCurrency": "Currency",
|
||||
"accountStatus": "Status",
|
||||
"accountLanguage": "Language",
|
||||
"accountTimezone": "Time zone",
|
||||
"accountRegion": "Region",
|
||||
"accountVipLevel": "VIP level",
|
||||
"accountCreatedAt": "Registered",
|
||||
"accountLastLogin": "Last login",
|
||||
"accountDevicesEmpty": "No login devices found.",
|
||||
"accountDevicesLoadFailed": "Could not load devices.",
|
||||
"accountPlatform": "Platform",
|
||||
"accountDeviceId": "Device ID",
|
||||
"accountFirstSeen": "First seen",
|
||||
"accountLastActive": "Last active",
|
||||
"accountDeviceActive": "Active",
|
||||
"accountDeviceInactive": "Inactive",
|
||||
"accountExportLoadFailed": "Could not export account data.",
|
||||
"accountCredentials": "Sign-in credentials",
|
||||
"accountConsentRecords": "Consent records",
|
||||
"accountDeviceCount": "Devices",
|
||||
"accountCopyJson": "Copy full JSON",
|
||||
"accountCopied": "Account data copied.",
|
||||
"accountVerified": "Verified",
|
||||
"accountUnverified": "Unverified",
|
||||
"accountDeactivateWarning": "Requests enter a 15-day cooling-off period. All devices are signed out immediately.",
|
||||
"accountDeactivateReason": "Reason (optional)",
|
||||
"accountDeactivateReasonHint": "Tell us why you are leaving",
|
||||
"accountRequestDeactivate": "Request account deletion",
|
||||
"accountConfirmDeactivateTitle": "Request account deletion?",
|
||||
"accountConfirmDeactivateMessage": "All devices will be signed out immediately. You can cancel during the 15-day cooling-off period after signing in again.",
|
||||
"accountConfirmDeactivate": "Confirm",
|
||||
"accountScheduledFor": "Deletion scheduled for {time}",
|
||||
"@accountScheduledFor": {
|
||||
"placeholders": {
|
||||
"time": {}
|
||||
}
|
||||
},
|
||||
"accountCancelDeactivation": "Cancel deletion request",
|
||||
"accountCancelSucceeded": "Deletion request cancelled.",
|
||||
"accountDeactivateFailed": "Could not request account deletion.",
|
||||
"accountCancelFailed": "Could not cancel the deletion request."
|
||||
}
|
||||
+62
-1
@@ -386,5 +386,66 @@
|
||||
"position": {},
|
||||
"total": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"accountCenter": "Akun",
|
||||
"accountProfile": "Profil",
|
||||
"accountDevices": "Perangkat masuk",
|
||||
"accountDataExport": "Ekspor data",
|
||||
"accountDeactivate": "Hapus akun",
|
||||
"accountProfileLoadFailed": "Profil gagal dimuat.",
|
||||
"accountProfileSaveFailed": "Profil gagal disimpan.",
|
||||
"accountNickname": "Nama panggilan",
|
||||
"accountNicknameInvalid": "Masukkan 1 hingga 30 karakter.",
|
||||
"accountGender": "Jenis kelamin",
|
||||
"accountMale": "Laki-laki",
|
||||
"accountFemale": "Perempuan",
|
||||
"accountUnknownGender": "Tidak ingin menyebutkan",
|
||||
"accountSelectBirthday": "Atur tanggal lahir",
|
||||
"accountBirthdayHint": "Tanggal lahir dapat diatur, tetapi belum dapat dihapus pada versi ini.",
|
||||
"accountSaved": "Profil disimpan.",
|
||||
"accountReadOnlyInfo": "Informasi akun",
|
||||
"accountUid": "UID",
|
||||
"accountMaskedAccount": "Akun",
|
||||
"accountCountry": "Negara",
|
||||
"accountCurrency": "Mata uang",
|
||||
"accountStatus": "Status",
|
||||
"accountLanguage": "Bahasa",
|
||||
"accountTimezone": "Zona waktu",
|
||||
"accountRegion": "Wilayah",
|
||||
"accountVipLevel": "Level VIP",
|
||||
"accountCreatedAt": "Terdaftar",
|
||||
"accountLastLogin": "Terakhir masuk",
|
||||
"accountDevicesEmpty": "Belum ada perangkat yang masuk.",
|
||||
"accountDevicesLoadFailed": "Perangkat gagal dimuat.",
|
||||
"accountPlatform": "Platform",
|
||||
"accountDeviceId": "ID perangkat",
|
||||
"accountFirstSeen": "Pertama terlihat",
|
||||
"accountLastActive": "Terakhir aktif",
|
||||
"accountDeviceActive": "Aktif",
|
||||
"accountDeviceInactive": "Tidak aktif",
|
||||
"accountExportLoadFailed": "Data akun gagal diekspor.",
|
||||
"accountCredentials": "Kredensial masuk",
|
||||
"accountConsentRecords": "Catatan persetujuan",
|
||||
"accountDeviceCount": "Perangkat",
|
||||
"accountCopyJson": "Salin JSON lengkap",
|
||||
"accountCopied": "Data akun disalin.",
|
||||
"accountVerified": "Terverifikasi",
|
||||
"accountUnverified": "Belum terverifikasi",
|
||||
"accountDeactivateWarning": "Permintaan masuk masa tenang 15 hari. Semua perangkat langsung keluar.",
|
||||
"accountDeactivateReason": "Alasan (opsional)",
|
||||
"accountDeactivateReasonHint": "Beri tahu alasan kamu",
|
||||
"accountRequestDeactivate": "Ajukan penghapusan akun",
|
||||
"accountConfirmDeactivateTitle": "Ajukan penghapusan akun?",
|
||||
"accountConfirmDeactivateMessage": "Semua perangkat akan langsung keluar. Setelah masuk lagi, kamu dapat membatalkannya selama masa tenang 15 hari.",
|
||||
"accountConfirmDeactivate": "Konfirmasi",
|
||||
"accountScheduledFor": "Penghapusan dijadwalkan pada {time}",
|
||||
"@accountScheduledFor": {
|
||||
"placeholders": {
|
||||
"time": {}
|
||||
}
|
||||
},
|
||||
"accountCancelDeactivation": "Batalkan permintaan penghapusan",
|
||||
"accountCancelSucceeded": "Permintaan penghapusan dibatalkan.",
|
||||
"accountDeactivateFailed": "Permintaan penghapusan gagal dikirim.",
|
||||
"accountCancelFailed": "Permintaan penghapusan gagal dibatalkan."
|
||||
}
|
||||
@@ -1677,6 +1677,342 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Resume at {position} / {total}'**
|
||||
String resumeProgress(Object position, Object total);
|
||||
|
||||
/// No description provided for @accountCenter.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Account'**
|
||||
String get accountCenter;
|
||||
|
||||
/// No description provided for @accountProfile.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Profile'**
|
||||
String get accountProfile;
|
||||
|
||||
/// No description provided for @accountDevices.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Login devices'**
|
||||
String get accountDevices;
|
||||
|
||||
/// No description provided for @accountDataExport.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Data export'**
|
||||
String get accountDataExport;
|
||||
|
||||
/// No description provided for @accountDeactivate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete account'**
|
||||
String get accountDeactivate;
|
||||
|
||||
/// No description provided for @accountProfileLoadFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not load your profile.'**
|
||||
String get accountProfileLoadFailed;
|
||||
|
||||
/// No description provided for @accountProfileSaveFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not save your profile.'**
|
||||
String get accountProfileSaveFailed;
|
||||
|
||||
/// No description provided for @accountNickname.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nickname'**
|
||||
String get accountNickname;
|
||||
|
||||
/// No description provided for @accountNicknameInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter 1 to 30 characters.'**
|
||||
String get accountNicknameInvalid;
|
||||
|
||||
/// No description provided for @accountGender.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Gender'**
|
||||
String get accountGender;
|
||||
|
||||
/// No description provided for @accountMale.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Male'**
|
||||
String get accountMale;
|
||||
|
||||
/// No description provided for @accountFemale.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Female'**
|
||||
String get accountFemale;
|
||||
|
||||
/// No description provided for @accountUnknownGender.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Prefer not to say'**
|
||||
String get accountUnknownGender;
|
||||
|
||||
/// No description provided for @accountSelectBirthday.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Set birthday'**
|
||||
String get accountSelectBirthday;
|
||||
|
||||
/// No description provided for @accountBirthdayHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Birthday can be set but not cleared in this version.'**
|
||||
String get accountBirthdayHint;
|
||||
|
||||
/// No description provided for @accountSaved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Profile saved.'**
|
||||
String get accountSaved;
|
||||
|
||||
/// No description provided for @accountReadOnlyInfo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Account information'**
|
||||
String get accountReadOnlyInfo;
|
||||
|
||||
/// No description provided for @accountUid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'UID'**
|
||||
String get accountUid;
|
||||
|
||||
/// No description provided for @accountMaskedAccount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Account'**
|
||||
String get accountMaskedAccount;
|
||||
|
||||
/// No description provided for @accountCountry.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Country'**
|
||||
String get accountCountry;
|
||||
|
||||
/// No description provided for @accountCurrency.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Currency'**
|
||||
String get accountCurrency;
|
||||
|
||||
/// No description provided for @accountStatus.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Status'**
|
||||
String get accountStatus;
|
||||
|
||||
/// No description provided for @accountLanguage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Language'**
|
||||
String get accountLanguage;
|
||||
|
||||
/// No description provided for @accountTimezone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Time zone'**
|
||||
String get accountTimezone;
|
||||
|
||||
/// No description provided for @accountRegion.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Region'**
|
||||
String get accountRegion;
|
||||
|
||||
/// No description provided for @accountVipLevel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'VIP level'**
|
||||
String get accountVipLevel;
|
||||
|
||||
/// No description provided for @accountCreatedAt.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Registered'**
|
||||
String get accountCreatedAt;
|
||||
|
||||
/// No description provided for @accountLastLogin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last login'**
|
||||
String get accountLastLogin;
|
||||
|
||||
/// No description provided for @accountDevicesEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No login devices found.'**
|
||||
String get accountDevicesEmpty;
|
||||
|
||||
/// No description provided for @accountDevicesLoadFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not load devices.'**
|
||||
String get accountDevicesLoadFailed;
|
||||
|
||||
/// No description provided for @accountPlatform.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Platform'**
|
||||
String get accountPlatform;
|
||||
|
||||
/// No description provided for @accountDeviceId.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Device ID'**
|
||||
String get accountDeviceId;
|
||||
|
||||
/// No description provided for @accountFirstSeen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'First seen'**
|
||||
String get accountFirstSeen;
|
||||
|
||||
/// No description provided for @accountLastActive.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last active'**
|
||||
String get accountLastActive;
|
||||
|
||||
/// No description provided for @accountDeviceActive.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Active'**
|
||||
String get accountDeviceActive;
|
||||
|
||||
/// No description provided for @accountDeviceInactive.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Inactive'**
|
||||
String get accountDeviceInactive;
|
||||
|
||||
/// No description provided for @accountExportLoadFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not export account data.'**
|
||||
String get accountExportLoadFailed;
|
||||
|
||||
/// No description provided for @accountCredentials.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign-in credentials'**
|
||||
String get accountCredentials;
|
||||
|
||||
/// No description provided for @accountConsentRecords.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Consent records'**
|
||||
String get accountConsentRecords;
|
||||
|
||||
/// No description provided for @accountDeviceCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Devices'**
|
||||
String get accountDeviceCount;
|
||||
|
||||
/// No description provided for @accountCopyJson.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Copy full JSON'**
|
||||
String get accountCopyJson;
|
||||
|
||||
/// No description provided for @accountCopied.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Account data copied.'**
|
||||
String get accountCopied;
|
||||
|
||||
/// No description provided for @accountVerified.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Verified'**
|
||||
String get accountVerified;
|
||||
|
||||
/// No description provided for @accountUnverified.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unverified'**
|
||||
String get accountUnverified;
|
||||
|
||||
/// No description provided for @accountDeactivateWarning.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Requests enter a 15-day cooling-off period. All devices are signed out immediately.'**
|
||||
String get accountDeactivateWarning;
|
||||
|
||||
/// No description provided for @accountDeactivateReason.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reason (optional)'**
|
||||
String get accountDeactivateReason;
|
||||
|
||||
/// No description provided for @accountDeactivateReasonHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tell us why you are leaving'**
|
||||
String get accountDeactivateReasonHint;
|
||||
|
||||
/// No description provided for @accountRequestDeactivate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Request account deletion'**
|
||||
String get accountRequestDeactivate;
|
||||
|
||||
/// No description provided for @accountConfirmDeactivateTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Request account deletion?'**
|
||||
String get accountConfirmDeactivateTitle;
|
||||
|
||||
/// No description provided for @accountConfirmDeactivateMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All devices will be signed out immediately. You can cancel during the 15-day cooling-off period after signing in again.'**
|
||||
String get accountConfirmDeactivateMessage;
|
||||
|
||||
/// No description provided for @accountConfirmDeactivate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Confirm'**
|
||||
String get accountConfirmDeactivate;
|
||||
|
||||
/// No description provided for @accountScheduledFor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Deletion scheduled for {time}'**
|
||||
String accountScheduledFor(Object time);
|
||||
|
||||
/// No description provided for @accountCancelDeactivation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cancel deletion request'**
|
||||
String get accountCancelDeactivation;
|
||||
|
||||
/// No description provided for @accountCancelSucceeded.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Deletion request cancelled.'**
|
||||
String get accountCancelSucceeded;
|
||||
|
||||
/// No description provided for @accountDeactivateFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not request account deletion.'**
|
||||
String get accountDeactivateFailed;
|
||||
|
||||
/// No description provided for @accountCancelFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not cancel the deletion request.'**
|
||||
String get accountCancelFailed;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -849,4 +849,177 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String resumeProgress(Object position, Object total) {
|
||||
return 'Resume at $position / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get accountCenter => 'Account';
|
||||
|
||||
@override
|
||||
String get accountProfile => 'Profile';
|
||||
|
||||
@override
|
||||
String get accountDevices => 'Login devices';
|
||||
|
||||
@override
|
||||
String get accountDataExport => 'Data export';
|
||||
|
||||
@override
|
||||
String get accountDeactivate => 'Delete account';
|
||||
|
||||
@override
|
||||
String get accountProfileLoadFailed => 'Could not load your profile.';
|
||||
|
||||
@override
|
||||
String get accountProfileSaveFailed => 'Could not save your profile.';
|
||||
|
||||
@override
|
||||
String get accountNickname => 'Nickname';
|
||||
|
||||
@override
|
||||
String get accountNicknameInvalid => 'Enter 1 to 30 characters.';
|
||||
|
||||
@override
|
||||
String get accountGender => 'Gender';
|
||||
|
||||
@override
|
||||
String get accountMale => 'Male';
|
||||
|
||||
@override
|
||||
String get accountFemale => 'Female';
|
||||
|
||||
@override
|
||||
String get accountUnknownGender => 'Prefer not to say';
|
||||
|
||||
@override
|
||||
String get accountSelectBirthday => 'Set birthday';
|
||||
|
||||
@override
|
||||
String get accountBirthdayHint =>
|
||||
'Birthday can be set but not cleared in this version.';
|
||||
|
||||
@override
|
||||
String get accountSaved => 'Profile saved.';
|
||||
|
||||
@override
|
||||
String get accountReadOnlyInfo => 'Account information';
|
||||
|
||||
@override
|
||||
String get accountUid => 'UID';
|
||||
|
||||
@override
|
||||
String get accountMaskedAccount => 'Account';
|
||||
|
||||
@override
|
||||
String get accountCountry => 'Country';
|
||||
|
||||
@override
|
||||
String get accountCurrency => 'Currency';
|
||||
|
||||
@override
|
||||
String get accountStatus => 'Status';
|
||||
|
||||
@override
|
||||
String get accountLanguage => 'Language';
|
||||
|
||||
@override
|
||||
String get accountTimezone => 'Time zone';
|
||||
|
||||
@override
|
||||
String get accountRegion => 'Region';
|
||||
|
||||
@override
|
||||
String get accountVipLevel => 'VIP level';
|
||||
|
||||
@override
|
||||
String get accountCreatedAt => 'Registered';
|
||||
|
||||
@override
|
||||
String get accountLastLogin => 'Last login';
|
||||
|
||||
@override
|
||||
String get accountDevicesEmpty => 'No login devices found.';
|
||||
|
||||
@override
|
||||
String get accountDevicesLoadFailed => 'Could not load devices.';
|
||||
|
||||
@override
|
||||
String get accountPlatform => 'Platform';
|
||||
|
||||
@override
|
||||
String get accountDeviceId => 'Device ID';
|
||||
|
||||
@override
|
||||
String get accountFirstSeen => 'First seen';
|
||||
|
||||
@override
|
||||
String get accountLastActive => 'Last active';
|
||||
|
||||
@override
|
||||
String get accountDeviceActive => 'Active';
|
||||
|
||||
@override
|
||||
String get accountDeviceInactive => 'Inactive';
|
||||
|
||||
@override
|
||||
String get accountExportLoadFailed => 'Could not export account data.';
|
||||
|
||||
@override
|
||||
String get accountCredentials => 'Sign-in credentials';
|
||||
|
||||
@override
|
||||
String get accountConsentRecords => 'Consent records';
|
||||
|
||||
@override
|
||||
String get accountDeviceCount => 'Devices';
|
||||
|
||||
@override
|
||||
String get accountCopyJson => 'Copy full JSON';
|
||||
|
||||
@override
|
||||
String get accountCopied => 'Account data copied.';
|
||||
|
||||
@override
|
||||
String get accountVerified => 'Verified';
|
||||
|
||||
@override
|
||||
String get accountUnverified => 'Unverified';
|
||||
|
||||
@override
|
||||
String get accountDeactivateWarning =>
|
||||
'Requests enter a 15-day cooling-off period. All devices are signed out immediately.';
|
||||
|
||||
@override
|
||||
String get accountDeactivateReason => 'Reason (optional)';
|
||||
|
||||
@override
|
||||
String get accountDeactivateReasonHint => 'Tell us why you are leaving';
|
||||
|
||||
@override
|
||||
String get accountRequestDeactivate => 'Request account deletion';
|
||||
|
||||
@override
|
||||
String get accountConfirmDeactivateTitle => 'Request account deletion?';
|
||||
|
||||
@override
|
||||
String get accountConfirmDeactivateMessage =>
|
||||
'All devices will be signed out immediately. You can cancel during the 15-day cooling-off period after signing in again.';
|
||||
|
||||
@override
|
||||
String get accountConfirmDeactivate => 'Confirm';
|
||||
|
||||
@override
|
||||
String accountScheduledFor(Object time) {
|
||||
return 'Deletion scheduled for $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get accountCancelDeactivation => 'Cancel deletion request';
|
||||
|
||||
@override
|
||||
String get accountCancelSucceeded => 'Deletion request cancelled.';
|
||||
|
||||
@override
|
||||
String get accountDeactivateFailed => 'Could not request account deletion.';
|
||||
|
||||
@override
|
||||
String get accountCancelFailed => 'Could not cancel the deletion request.';
|
||||
}
|
||||
@@ -848,4 +848,177 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String resumeProgress(Object position, Object total) {
|
||||
return 'Lanjut di $position / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get accountCenter => 'Akun';
|
||||
|
||||
@override
|
||||
String get accountProfile => 'Profil';
|
||||
|
||||
@override
|
||||
String get accountDevices => 'Perangkat masuk';
|
||||
|
||||
@override
|
||||
String get accountDataExport => 'Ekspor data';
|
||||
|
||||
@override
|
||||
String get accountDeactivate => 'Hapus akun';
|
||||
|
||||
@override
|
||||
String get accountProfileLoadFailed => 'Profil gagal dimuat.';
|
||||
|
||||
@override
|
||||
String get accountProfileSaveFailed => 'Profil gagal disimpan.';
|
||||
|
||||
@override
|
||||
String get accountNickname => 'Nama panggilan';
|
||||
|
||||
@override
|
||||
String get accountNicknameInvalid => 'Masukkan 1 hingga 30 karakter.';
|
||||
|
||||
@override
|
||||
String get accountGender => 'Jenis kelamin';
|
||||
|
||||
@override
|
||||
String get accountMale => 'Laki-laki';
|
||||
|
||||
@override
|
||||
String get accountFemale => 'Perempuan';
|
||||
|
||||
@override
|
||||
String get accountUnknownGender => 'Tidak ingin menyebutkan';
|
||||
|
||||
@override
|
||||
String get accountSelectBirthday => 'Atur tanggal lahir';
|
||||
|
||||
@override
|
||||
String get accountBirthdayHint =>
|
||||
'Tanggal lahir dapat diatur, tetapi belum dapat dihapus pada versi ini.';
|
||||
|
||||
@override
|
||||
String get accountSaved => 'Profil disimpan.';
|
||||
|
||||
@override
|
||||
String get accountReadOnlyInfo => 'Informasi akun';
|
||||
|
||||
@override
|
||||
String get accountUid => 'UID';
|
||||
|
||||
@override
|
||||
String get accountMaskedAccount => 'Akun';
|
||||
|
||||
@override
|
||||
String get accountCountry => 'Negara';
|
||||
|
||||
@override
|
||||
String get accountCurrency => 'Mata uang';
|
||||
|
||||
@override
|
||||
String get accountStatus => 'Status';
|
||||
|
||||
@override
|
||||
String get accountLanguage => 'Bahasa';
|
||||
|
||||
@override
|
||||
String get accountTimezone => 'Zona waktu';
|
||||
|
||||
@override
|
||||
String get accountRegion => 'Wilayah';
|
||||
|
||||
@override
|
||||
String get accountVipLevel => 'Level VIP';
|
||||
|
||||
@override
|
||||
String get accountCreatedAt => 'Terdaftar';
|
||||
|
||||
@override
|
||||
String get accountLastLogin => 'Terakhir masuk';
|
||||
|
||||
@override
|
||||
String get accountDevicesEmpty => 'Belum ada perangkat yang masuk.';
|
||||
|
||||
@override
|
||||
String get accountDevicesLoadFailed => 'Perangkat gagal dimuat.';
|
||||
|
||||
@override
|
||||
String get accountPlatform => 'Platform';
|
||||
|
||||
@override
|
||||
String get accountDeviceId => 'ID perangkat';
|
||||
|
||||
@override
|
||||
String get accountFirstSeen => 'Pertama terlihat';
|
||||
|
||||
@override
|
||||
String get accountLastActive => 'Terakhir aktif';
|
||||
|
||||
@override
|
||||
String get accountDeviceActive => 'Aktif';
|
||||
|
||||
@override
|
||||
String get accountDeviceInactive => 'Tidak aktif';
|
||||
|
||||
@override
|
||||
String get accountExportLoadFailed => 'Data akun gagal diekspor.';
|
||||
|
||||
@override
|
||||
String get accountCredentials => 'Kredensial masuk';
|
||||
|
||||
@override
|
||||
String get accountConsentRecords => 'Catatan persetujuan';
|
||||
|
||||
@override
|
||||
String get accountDeviceCount => 'Perangkat';
|
||||
|
||||
@override
|
||||
String get accountCopyJson => 'Salin JSON lengkap';
|
||||
|
||||
@override
|
||||
String get accountCopied => 'Data akun disalin.';
|
||||
|
||||
@override
|
||||
String get accountVerified => 'Terverifikasi';
|
||||
|
||||
@override
|
||||
String get accountUnverified => 'Belum terverifikasi';
|
||||
|
||||
@override
|
||||
String get accountDeactivateWarning =>
|
||||
'Permintaan masuk masa tenang 15 hari. Semua perangkat langsung keluar.';
|
||||
|
||||
@override
|
||||
String get accountDeactivateReason => 'Alasan (opsional)';
|
||||
|
||||
@override
|
||||
String get accountDeactivateReasonHint => 'Beri tahu alasan kamu';
|
||||
|
||||
@override
|
||||
String get accountRequestDeactivate => 'Ajukan penghapusan akun';
|
||||
|
||||
@override
|
||||
String get accountConfirmDeactivateTitle => 'Ajukan penghapusan akun?';
|
||||
|
||||
@override
|
||||
String get accountConfirmDeactivateMessage =>
|
||||
'Semua perangkat akan langsung keluar. Setelah masuk lagi, kamu dapat membatalkannya selama masa tenang 15 hari.';
|
||||
|
||||
@override
|
||||
String get accountConfirmDeactivate => 'Konfirmasi';
|
||||
|
||||
@override
|
||||
String accountScheduledFor(Object time) {
|
||||
return 'Penghapusan dijadwalkan pada $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get accountCancelDeactivation => 'Batalkan permintaan penghapusan';
|
||||
|
||||
@override
|
||||
String get accountCancelSucceeded => 'Permintaan penghapusan dibatalkan.';
|
||||
|
||||
@override
|
||||
String get accountDeactivateFailed => 'Permintaan penghapusan gagal dikirim.';
|
||||
|
||||
@override
|
||||
String get accountCancelFailed => 'Permintaan penghapusan gagal dibatalkan.';
|
||||
}
|
||||
@@ -832,4 +832,175 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String resumeProgress(Object position, Object total) {
|
||||
return '续看至 $position / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get accountCenter => '账号';
|
||||
|
||||
@override
|
||||
String get accountProfile => '个人资料';
|
||||
|
||||
@override
|
||||
String get accountDevices => '登录设备';
|
||||
|
||||
@override
|
||||
String get accountDataExport => '数据导出';
|
||||
|
||||
@override
|
||||
String get accountDeactivate => '注销账号';
|
||||
|
||||
@override
|
||||
String get accountProfileLoadFailed => '个人资料加载失败。';
|
||||
|
||||
@override
|
||||
String get accountProfileSaveFailed => '个人资料保存失败。';
|
||||
|
||||
@override
|
||||
String get accountNickname => '昵称';
|
||||
|
||||
@override
|
||||
String get accountNicknameInvalid => '请输入 1 至 30 个字符。';
|
||||
|
||||
@override
|
||||
String get accountGender => '性别';
|
||||
|
||||
@override
|
||||
String get accountMale => '男';
|
||||
|
||||
@override
|
||||
String get accountFemale => '女';
|
||||
|
||||
@override
|
||||
String get accountUnknownGender => '不愿透露';
|
||||
|
||||
@override
|
||||
String get accountSelectBirthday => '设置生日';
|
||||
|
||||
@override
|
||||
String get accountBirthdayHint => '当前版本只能设置生日,不能清空。';
|
||||
|
||||
@override
|
||||
String get accountSaved => '个人资料已保存。';
|
||||
|
||||
@override
|
||||
String get accountReadOnlyInfo => '账号信息';
|
||||
|
||||
@override
|
||||
String get accountUid => 'UID';
|
||||
|
||||
@override
|
||||
String get accountMaskedAccount => '账号';
|
||||
|
||||
@override
|
||||
String get accountCountry => '国家';
|
||||
|
||||
@override
|
||||
String get accountCurrency => '币种';
|
||||
|
||||
@override
|
||||
String get accountStatus => '状态';
|
||||
|
||||
@override
|
||||
String get accountLanguage => '语言';
|
||||
|
||||
@override
|
||||
String get accountTimezone => '时区';
|
||||
|
||||
@override
|
||||
String get accountRegion => '地区';
|
||||
|
||||
@override
|
||||
String get accountVipLevel => 'VIP 等级';
|
||||
|
||||
@override
|
||||
String get accountCreatedAt => '注册时间';
|
||||
|
||||
@override
|
||||
String get accountLastLogin => '最近登录';
|
||||
|
||||
@override
|
||||
String get accountDevicesEmpty => '暂无登录设备。';
|
||||
|
||||
@override
|
||||
String get accountDevicesLoadFailed => '登录设备加载失败。';
|
||||
|
||||
@override
|
||||
String get accountPlatform => '平台';
|
||||
|
||||
@override
|
||||
String get accountDeviceId => '设备号';
|
||||
|
||||
@override
|
||||
String get accountFirstSeen => '首次登录';
|
||||
|
||||
@override
|
||||
String get accountLastActive => '最近活跃';
|
||||
|
||||
@override
|
||||
String get accountDeviceActive => '活跃';
|
||||
|
||||
@override
|
||||
String get accountDeviceInactive => '不活跃';
|
||||
|
||||
@override
|
||||
String get accountExportLoadFailed => '账号数据导出失败。';
|
||||
|
||||
@override
|
||||
String get accountCredentials => '登录凭据';
|
||||
|
||||
@override
|
||||
String get accountConsentRecords => '授权记录';
|
||||
|
||||
@override
|
||||
String get accountDeviceCount => '设备数';
|
||||
|
||||
@override
|
||||
String get accountCopyJson => '复制完整 JSON';
|
||||
|
||||
@override
|
||||
String get accountCopied => '账号数据已复制。';
|
||||
|
||||
@override
|
||||
String get accountVerified => '已验证';
|
||||
|
||||
@override
|
||||
String get accountUnverified => '未验证';
|
||||
|
||||
@override
|
||||
String get accountDeactivateWarning => '提交后进入 15 天冷静期,所有设备会立即退出登录。';
|
||||
|
||||
@override
|
||||
String get accountDeactivateReason => '注销原因(选填)';
|
||||
|
||||
@override
|
||||
String get accountDeactivateReasonHint => '告诉我们你离开的原因';
|
||||
|
||||
@override
|
||||
String get accountRequestDeactivate => '申请注销账号';
|
||||
|
||||
@override
|
||||
String get accountConfirmDeactivateTitle => '确认申请注销?';
|
||||
|
||||
@override
|
||||
String get accountConfirmDeactivateMessage =>
|
||||
'所有设备会立即退出登录。重新登录后,可在 15 天冷静期内撤销申请。';
|
||||
|
||||
@override
|
||||
String get accountConfirmDeactivate => '确认注销';
|
||||
|
||||
@override
|
||||
String accountScheduledFor(Object time) {
|
||||
return '预计注销时间:$time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get accountCancelDeactivation => '撤销注销申请';
|
||||
|
||||
@override
|
||||
String get accountCancelSucceeded => '注销申请已撤销。';
|
||||
|
||||
@override
|
||||
String get accountDeactivateFailed => '注销申请提交失败。';
|
||||
|
||||
@override
|
||||
String get accountCancelFailed => '注销申请撤销失败。';
|
||||
}
|
||||
+62
-1
@@ -348,5 +348,66 @@
|
||||
"position": {},
|
||||
"total": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"accountCenter": "账号",
|
||||
"accountProfile": "个人资料",
|
||||
"accountDevices": "登录设备",
|
||||
"accountDataExport": "数据导出",
|
||||
"accountDeactivate": "注销账号",
|
||||
"accountProfileLoadFailed": "个人资料加载失败。",
|
||||
"accountProfileSaveFailed": "个人资料保存失败。",
|
||||
"accountNickname": "昵称",
|
||||
"accountNicknameInvalid": "请输入 1 至 30 个字符。",
|
||||
"accountGender": "性别",
|
||||
"accountMale": "男",
|
||||
"accountFemale": "女",
|
||||
"accountUnknownGender": "不愿透露",
|
||||
"accountSelectBirthday": "设置生日",
|
||||
"accountBirthdayHint": "当前版本只能设置生日,不能清空。",
|
||||
"accountSaved": "个人资料已保存。",
|
||||
"accountReadOnlyInfo": "账号信息",
|
||||
"accountUid": "UID",
|
||||
"accountMaskedAccount": "账号",
|
||||
"accountCountry": "国家",
|
||||
"accountCurrency": "币种",
|
||||
"accountStatus": "状态",
|
||||
"accountLanguage": "语言",
|
||||
"accountTimezone": "时区",
|
||||
"accountRegion": "地区",
|
||||
"accountVipLevel": "VIP 等级",
|
||||
"accountCreatedAt": "注册时间",
|
||||
"accountLastLogin": "最近登录",
|
||||
"accountDevicesEmpty": "暂无登录设备。",
|
||||
"accountDevicesLoadFailed": "登录设备加载失败。",
|
||||
"accountPlatform": "平台",
|
||||
"accountDeviceId": "设备号",
|
||||
"accountFirstSeen": "首次登录",
|
||||
"accountLastActive": "最近活跃",
|
||||
"accountDeviceActive": "活跃",
|
||||
"accountDeviceInactive": "不活跃",
|
||||
"accountExportLoadFailed": "账号数据导出失败。",
|
||||
"accountCredentials": "登录凭据",
|
||||
"accountConsentRecords": "授权记录",
|
||||
"accountDeviceCount": "设备数",
|
||||
"accountCopyJson": "复制完整 JSON",
|
||||
"accountCopied": "账号数据已复制。",
|
||||
"accountVerified": "已验证",
|
||||
"accountUnverified": "未验证",
|
||||
"accountDeactivateWarning": "提交后进入 15 天冷静期,所有设备会立即退出登录。",
|
||||
"accountDeactivateReason": "注销原因(选填)",
|
||||
"accountDeactivateReasonHint": "告诉我们你离开的原因",
|
||||
"accountRequestDeactivate": "申请注销账号",
|
||||
"accountConfirmDeactivateTitle": "确认申请注销?",
|
||||
"accountConfirmDeactivateMessage": "所有设备会立即退出登录。重新登录后,可在 15 天冷静期内撤销申请。",
|
||||
"accountConfirmDeactivate": "确认注销",
|
||||
"accountScheduledFor": "预计注销时间:{time}",
|
||||
"@accountScheduledFor": {
|
||||
"placeholders": {
|
||||
"time": {}
|
||||
}
|
||||
},
|
||||
"accountCancelDeactivation": "撤销注销申请",
|
||||
"accountCancelSucceeded": "注销申请已撤销。",
|
||||
"accountDeactivateFailed": "注销申请提交失败。",
|
||||
"accountCancelFailed": "注销申请撤销失败。"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../core/data/models/account_profile.dart';
|
||||
import '../../core/data/repositories/account_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import 'account_deactivate_logic.dart';
|
||||
import 'account_devices_logic.dart';
|
||||
import 'account_export_logic.dart';
|
||||
import 'account_profile_logic.dart';
|
||||
|
||||
/// 账号中心页面依赖装配。
|
||||
class AccountProfileBinding {
|
||||
AccountProfileLogic create(
|
||||
AccountRepository repository,
|
||||
AuthController auth,
|
||||
) => AccountProfileLogic(
|
||||
repository,
|
||||
onProfileUpdated: (AccountProfile profile) =>
|
||||
auth.applyProfile(profile.toAuthUser()),
|
||||
);
|
||||
}
|
||||
|
||||
class AccountDevicesBinding {
|
||||
AccountDevicesLogic create(AccountRepository repository) =>
|
||||
AccountDevicesLogic(repository);
|
||||
}
|
||||
|
||||
class AccountExportBinding {
|
||||
AccountExportLogic create(
|
||||
AccountRepository repository,
|
||||
Future<void> Function(String text) copyText,
|
||||
) => AccountExportLogic(repository, copyText: copyText);
|
||||
}
|
||||
|
||||
class AccountDeactivateBinding {
|
||||
AccountDeactivateLogic create(
|
||||
AccountRepository repository,
|
||||
AuthController auth,
|
||||
) => AccountDeactivateLogic(repository, clearSession: auth.clear);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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,210 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/repositories/account_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/formatting/localized_date_time.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'account_bindings.dart';
|
||||
import 'account_deactivate_logic.dart';
|
||||
|
||||
/// 注销申请与冷静期撤销页。
|
||||
class AccountDeactivatePage extends StatefulWidget {
|
||||
const AccountDeactivatePage({super.key});
|
||||
|
||||
@override
|
||||
State<AccountDeactivatePage> createState() => _AccountDeactivatePageState();
|
||||
}
|
||||
|
||||
class _AccountDeactivatePageState extends State<AccountDeactivatePage> {
|
||||
late final AccountDeactivateLogic _logic;
|
||||
final _reason = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_logic = AccountDeactivateBinding().create(
|
||||
context.read<AccountRepository>(),
|
||||
context.read<AuthController>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logic.dispose();
|
||||
_reason.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
final canShow = signedIn || _logic.scheduledAt != null;
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: Scaffold(
|
||||
key: const Key('account-deactivate-page'),
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
title: Text(l10n.accountDeactivate),
|
||||
),
|
||||
body: canShow
|
||||
? ListenableBuilder(
|
||||
listenable: _logic,
|
||||
builder: (context, _) => _body(l10n),
|
||||
)
|
||||
: const AuthGatePanel(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(AppLocalizations l10n) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
children: [
|
||||
_warning(l10n),
|
||||
const SizedBox(height: 14),
|
||||
if (_logic.scheduledAt != null)
|
||||
_scheduledPanel(l10n)
|
||||
else
|
||||
_requestPanel(l10n),
|
||||
const SizedBox(height: 14),
|
||||
_cancelPanel(l10n),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _warning(AppLocalizations l10n) {
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: errorColor.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: errorColor.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Text(
|
||||
l10n.accountDeactivateWarning,
|
||||
style: TextStyle(color: errorColor, height: 1.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _scheduledPanel(AppLocalizations l10n) => Container(
|
||||
key: const Key('account-deactivate-scheduled'),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(
|
||||
l10n.accountScheduledFor(
|
||||
formatLocalizedDateTime(context, _logic.scheduledAt!),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _requestPanel(AppLocalizations l10n) => Column(
|
||||
children: [
|
||||
TextField(
|
||||
key: const Key('account-deactivate-reason'),
|
||||
controller: _reason,
|
||||
maxLength: AccountDeactivateLogic.maxReasonLength,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.accountDeactivateReason,
|
||||
hintText: l10n.accountDeactivateReasonHint,
|
||||
),
|
||||
),
|
||||
if (_logic.error != null)
|
||||
_errorText(_message(l10n, _logic.error, l10n.accountDeactivateFailed)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
key: const Key('account-deactivate-submit'),
|
||||
onPressed: _logic.requesting ? null : _confirm,
|
||||
child: _logic.requesting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(l10n.accountRequestDeactivate),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _cancelPanel(AppLocalizations l10n) => Column(
|
||||
children: [
|
||||
OutlinedButton(
|
||||
key: const Key('account-deactivate-cancel'),
|
||||
onPressed: _logic.cancelling ? null : _cancel,
|
||||
child: Text(l10n.accountCancelDeactivation),
|
||||
),
|
||||
if (_logic.actionMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_errorText(
|
||||
_message(l10n, _logic.actionMessage, l10n.accountCancelFailed),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Widget _errorText(String value) => Text(
|
||||
value,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
|
||||
);
|
||||
|
||||
Future<void> _confirm() async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(l10n.accountConfirmDeactivateTitle),
|
||||
content: Text(l10n.accountConfirmDeactivateMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
key: const Key('account-deactivate-confirm'),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.accountConfirmDeactivate),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await _logic.request(_reason.text);
|
||||
}
|
||||
|
||||
Future<void> _cancel() async {
|
||||
final ok = await _logic.cancel();
|
||||
if (!mounted) return;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (ok) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.accountCancelSucceeded)));
|
||||
}
|
||||
}
|
||||
|
||||
String _message(AppLocalizations l10n, String? value, String fallback) {
|
||||
if (value == null || value.isEmpty) return fallback;
|
||||
return switch (value) {
|
||||
'accountDeactivateFailed' => l10n.accountDeactivateFailed,
|
||||
'accountCancelFailed' => l10n.accountCancelFailed,
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,216 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/account_device.dart';
|
||||
import '../../core/data/repositories/account_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/formatting/localized_date_time.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'account_bindings.dart';
|
||||
import 'account_devices_logic.dart';
|
||||
|
||||
const _maskVisible = 4;
|
||||
|
||||
/// 登录设备只读列表页。
|
||||
class AccountDevicesPage extends StatefulWidget {
|
||||
const AccountDevicesPage({super.key});
|
||||
|
||||
@override
|
||||
State<AccountDevicesPage> createState() => _AccountDevicesPageState();
|
||||
}
|
||||
|
||||
class _AccountDevicesPageState extends State<AccountDevicesPage> {
|
||||
late final AccountDevicesLogic _logic;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_logic = AccountDevicesBinding().create(context.read<AccountRepository>());
|
||||
unawaited(_logic.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logic.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: Scaffold(
|
||||
key: const Key('account-devices-page'),
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
title: Text(l10n.accountDevices),
|
||||
),
|
||||
body: signedIn
|
||||
? ListenableBuilder(
|
||||
listenable: _logic,
|
||||
builder: (context, _) => _body(l10n),
|
||||
)
|
||||
: const AuthGatePanel(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(AppLocalizations l10n) {
|
||||
if (_logic.loading && _logic.devices.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_logic.error != null && _logic.devices.isEmpty) {
|
||||
return EmptyState(
|
||||
key: const Key('account-devices-error'),
|
||||
icon: Icons.cloud_off_rounded,
|
||||
title: errorText(l10n, _logic.error, l10n.accountDevicesLoadFailed),
|
||||
actionLabel: l10n.retry,
|
||||
actionKey: const Key('account-devices-retry'),
|
||||
onAction: _logic.load,
|
||||
);
|
||||
}
|
||||
if (_logic.devices.isEmpty) {
|
||||
return EmptyState(
|
||||
key: const Key('account-devices-empty'),
|
||||
icon: Icons.devices_other_rounded,
|
||||
title: l10n.accountDevicesEmpty,
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
itemCount: _logic.devices.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, index) =>
|
||||
_DeviceTile(device: _logic.devices[index], l10n: l10n),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceTile extends StatelessWidget {
|
||||
const _DeviceTile({required this.device, required this.l10n});
|
||||
|
||||
final AccountDevice device;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: ValueKey('account-device-${device.id}'),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.outlineVariantDark),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.phone_android_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
device.deviceModel.isEmpty
|
||||
? device.platform
|
||||
: device.deviceModel,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
_StatusChip(status: device.status, l10n: l10n),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_line(l10n.accountPlatform, device.platform),
|
||||
_line(l10n.accountDeviceId, _maskDeviceId(device.deviceId)),
|
||||
if (device.firstSeenAt != null)
|
||||
_line(
|
||||
l10n.accountFirstSeen,
|
||||
formatLocalizedDateTime(context, device.firstSeenAt!),
|
||||
),
|
||||
if (device.lastSeenAt != null)
|
||||
_line(
|
||||
l10n.accountLastActive,
|
||||
formatLocalizedDateTime(context, device.lastSeenAt!),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _line(String label, String value) => Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.status, required this.l10n});
|
||||
|
||||
final String status;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = status.toLowerCase() == 'active';
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? scheme.tertiary.withValues(alpha: 0.16)
|
||||
: AppColors.raised,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
active ? l10n.accountDeviceActive : l10n.accountDeviceInactive,
|
||||
style: TextStyle(
|
||||
color: active ? scheme.tertiary : AppColors.muted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _maskDeviceId(String value) {
|
||||
if (value.length <= _maskVisible * 2) return value;
|
||||
final head = value.substring(0, _maskVisible);
|
||||
final tail = value.substring(value.length - _maskVisible);
|
||||
return '$head****$tail';
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'account_deactivate_page.dart';
|
||||
import 'account_devices_page.dart';
|
||||
import 'account_export_page.dart';
|
||||
import 'account_profile_page.dart';
|
||||
|
||||
const _accountCardHeight = 48.0;
|
||||
const _accountTitleWidth = 64.0;
|
||||
|
||||
/// 我的页账号入口卡;只在登录态由父页面展示。
|
||||
class AccountEntryCard extends StatelessWidget {
|
||||
const AccountEntryCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Material(
|
||||
key: const Key('mine-account-card'),
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
height: _accountCardHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _accountTitleWidth,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Text(
|
||||
l10n.accountCenter,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const VerticalDivider(
|
||||
width: 1,
|
||||
color: AppColors.outlineVariantDark,
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
_entry(
|
||||
context,
|
||||
key: const Key('mine-account-profile'),
|
||||
icon: Icons.person_outline_rounded,
|
||||
title: l10n.accountProfile,
|
||||
page: const AccountProfilePage(),
|
||||
divider: true,
|
||||
),
|
||||
_entry(
|
||||
context,
|
||||
key: const Key('mine-account-devices'),
|
||||
icon: Icons.devices_other_rounded,
|
||||
title: l10n.accountDevices,
|
||||
page: const AccountDevicesPage(),
|
||||
divider: true,
|
||||
),
|
||||
_entry(
|
||||
context,
|
||||
key: const Key('mine-account-export'),
|
||||
icon: Icons.file_download_outlined,
|
||||
title: l10n.accountDataExport,
|
||||
page: const AccountExportPage(),
|
||||
divider: true,
|
||||
),
|
||||
_entry(
|
||||
context,
|
||||
key: const Key('mine-account-deactivate'),
|
||||
icon: Icons.person_remove_outlined,
|
||||
title: l10n.accountDeactivate,
|
||||
page: const AccountDeactivatePage(),
|
||||
divider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _entry(
|
||||
BuildContext context, {
|
||||
required Key key,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required Widget page,
|
||||
required bool divider,
|
||||
}) {
|
||||
return Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
key: key,
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => page)),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: AppColors.blueSoft, size: 16),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 9.5, height: 1.05),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (divider)
|
||||
const VerticalDivider(
|
||||
width: 1,
|
||||
color: AppColors.outlineVariantDark,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,181 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/account_export.dart';
|
||||
import '../../core/data/repositories/account_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'account_bindings.dart';
|
||||
import 'account_export_logic.dart';
|
||||
|
||||
/// 账号数据导出摘要页。
|
||||
class AccountExportPage extends StatefulWidget {
|
||||
const AccountExportPage({super.key});
|
||||
|
||||
@override
|
||||
State<AccountExportPage> createState() => _AccountExportPageState();
|
||||
}
|
||||
|
||||
class _AccountExportPageState extends State<AccountExportPage> {
|
||||
late final AccountExportLogic _logic;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_logic = AccountExportBinding().create(
|
||||
context.read<AccountRepository>(),
|
||||
(text) => Clipboard.setData(ClipboardData(text: text)),
|
||||
);
|
||||
unawaited(_logic.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logic.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: Scaffold(
|
||||
key: const Key('account-export-page'),
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
title: Text(l10n.accountDataExport),
|
||||
),
|
||||
body: signedIn
|
||||
? ListenableBuilder(
|
||||
listenable: _logic,
|
||||
builder: (context, _) => _body(l10n),
|
||||
)
|
||||
: const AuthGatePanel(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(AppLocalizations l10n) {
|
||||
if (_logic.loading && _logic.data == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final data = _logic.data;
|
||||
if (data == null) {
|
||||
return EmptyState(
|
||||
key: const Key('account-export-error'),
|
||||
icon: Icons.cloud_off_rounded,
|
||||
title: errorText(l10n, _logic.error, l10n.accountExportLoadFailed),
|
||||
actionLabel: l10n.retry,
|
||||
actionKey: const Key('account-export-retry'),
|
||||
onAction: _logic.load,
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
key: const Key('account-export-content'),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
children: [
|
||||
_profileCard(data, l10n),
|
||||
const SizedBox(height: 12),
|
||||
_credentialsCard(data, l10n),
|
||||
const SizedBox(height: 16),
|
||||
_copyButton(l10n),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _profileCard(AccountExport data, AppLocalizations l10n) => _card(
|
||||
children: [
|
||||
Text(
|
||||
data.profile.nickname,
|
||||
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${l10n.accountUid}: ${data.profile.uid}',
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _credentialsCard(AccountExport data, AppLocalizations l10n) => _card(
|
||||
children: [
|
||||
Text(
|
||||
l10n.accountCredentials,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
for (final credential in data.credentials)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(
|
||||
Icons.verified_user_outlined,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
title: Text(credential.masked),
|
||||
subtitle: Text(credential.type),
|
||||
trailing: Text(
|
||||
credential.verified ? l10n.accountVerified : l10n.accountUnverified,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 11),
|
||||
),
|
||||
),
|
||||
_summary(l10n.accountConsentRecords, '${data.consentCount}'),
|
||||
_summary(l10n.accountDeviceCount, '${data.deviceCount}'),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _copyButton(AppLocalizations l10n) => FilledButton.icon(
|
||||
key: const Key('account-export-copy'),
|
||||
onPressed: _copy,
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
label: Text(l10n.accountCopyJson),
|
||||
);
|
||||
|
||||
Widget _card({required List<Widget> children}) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.outlineVariantDark),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _summary(String label, String value) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _copy() async {
|
||||
final ok = await _logic.copy();
|
||||
if (!ok || !mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.accountCopied)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,348 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/account_profile.dart';
|
||||
import '../../core/data/repositories/account_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/formatting/localized_date_time.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'account_bindings.dart';
|
||||
import 'account_profile_logic.dart';
|
||||
|
||||
const _avatarSize = 64.0;
|
||||
const _fieldGap = 14.0;
|
||||
const _datePadWidth = 2;
|
||||
|
||||
/// 个人资料编辑页。
|
||||
class AccountProfilePage extends StatefulWidget {
|
||||
const AccountProfilePage({super.key});
|
||||
|
||||
@override
|
||||
State<AccountProfilePage> createState() => _AccountProfilePageState();
|
||||
}
|
||||
|
||||
class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
late final AccountProfileLogic _logic;
|
||||
final _nickname = TextEditingController();
|
||||
var _gender = 'unknown';
|
||||
String? _birthday;
|
||||
bool _formReady = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_logic = AccountProfileBinding().create(
|
||||
context.read<AccountRepository>(),
|
||||
context.read<AuthController>(),
|
||||
)..addListener(_syncProfile);
|
||||
unawaited(_logic.load());
|
||||
}
|
||||
|
||||
void _syncProfile() {
|
||||
final profile = _logic.profile;
|
||||
if (profile == null || _formReady) return;
|
||||
_nickname.text = profile.nickname;
|
||||
_gender = _knownGender(profile.gender);
|
||||
_formReady = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logic
|
||||
..removeListener(_syncProfile)
|
||||
..dispose();
|
||||
_nickname.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: Scaffold(
|
||||
key: const Key('account-profile-page'),
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
title: Text(l10n.accountProfile),
|
||||
),
|
||||
body: signedIn
|
||||
? ListenableBuilder(
|
||||
listenable: _logic,
|
||||
builder: (context, _) => _body(l10n),
|
||||
)
|
||||
: const AuthGatePanel(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(AppLocalizations l10n) {
|
||||
if (_logic.loading && _logic.profile == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final profile = _logic.profile;
|
||||
if (profile == null) {
|
||||
return EmptyState(
|
||||
key: const Key('account-profile-error'),
|
||||
icon: Icons.cloud_off_rounded,
|
||||
title: errorText(l10n, _logic.error, l10n.accountProfileLoadFailed),
|
||||
actionLabel: l10n.retry,
|
||||
actionKey: const Key('account-profile-retry'),
|
||||
onAction: _logic.load,
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
children: [
|
||||
_identity(profile, l10n),
|
||||
const SizedBox(height: 16),
|
||||
_editableForm(l10n),
|
||||
const SizedBox(height: 18),
|
||||
_readOnlyCard(profile, l10n),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _identity(AccountProfile profile, AppLocalizations l10n) {
|
||||
return Row(
|
||||
children: [
|
||||
ClipOval(
|
||||
child: SizedBox(
|
||||
width: _avatarSize,
|
||||
height: _avatarSize,
|
||||
child: profile.avatarUrl.isEmpty
|
||||
? const ColoredBox(
|
||||
color: AppColors.raised,
|
||||
child: Icon(Icons.person_rounded, size: 34),
|
||||
)
|
||||
: RemoteImage(profile.avatarUrl),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
profile.nickname,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${l10n.accountUid}: ${profile.uid}',
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _editableForm(AppLocalizations l10n) {
|
||||
return _AccountCard(
|
||||
child: Column(
|
||||
children: [
|
||||
_nicknameField(l10n),
|
||||
const SizedBox(height: _fieldGap),
|
||||
_genderField(l10n),
|
||||
const SizedBox(height: _fieldGap),
|
||||
_birthdayField(l10n),
|
||||
if (_logic.error != null) _saveError(l10n),
|
||||
const SizedBox(height: 16),
|
||||
_saveButton(l10n),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _nicknameField(AppLocalizations l10n) => TextField(
|
||||
key: const Key('account-nickname'),
|
||||
controller: _nickname,
|
||||
maxLength: AccountProfileLogic.maxNicknameLength,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.accountNickname,
|
||||
errorText: _logic.fieldError == null ? null : l10n.accountNicknameInvalid,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _genderField(AppLocalizations l10n) => DropdownButtonFormField<String>(
|
||||
key: const Key('account-gender'),
|
||||
initialValue: _gender,
|
||||
decoration: InputDecoration(labelText: l10n.accountGender),
|
||||
items: [
|
||||
DropdownMenuItem(value: 'male', child: Text(l10n.accountMale)),
|
||||
DropdownMenuItem(value: 'female', child: Text(l10n.accountFemale)),
|
||||
DropdownMenuItem(
|
||||
value: 'unknown',
|
||||
child: Text(l10n.accountUnknownGender),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _gender = value);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _birthdayField(AppLocalizations l10n) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
key: const Key('account-birthday'),
|
||||
onPressed: _pickBirthday,
|
||||
icon: const Icon(Icons.cake_outlined),
|
||||
label: Text(_birthday ?? l10n.accountSelectBirthday),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.accountBirthdayHint,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _saveError(AppLocalizations l10n) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
errorText(l10n, _logic.error, l10n.accountProfileSaveFailed),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _saveButton(AppLocalizations l10n) => SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: FilledButton(
|
||||
key: const Key('account-profile-save'),
|
||||
onPressed: _logic.saving ? null : _save,
|
||||
child: _logic.saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(l10n.save),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _readOnlyCard(AccountProfile profile, AppLocalizations l10n) {
|
||||
return _AccountCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.accountReadOnlyInfo,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_info(l10n.accountMaskedAccount, profile.maskedTarget),
|
||||
_info(l10n.accountCountry, profile.countryCode),
|
||||
_info(l10n.accountCurrency, profile.currency),
|
||||
_info(l10n.accountStatus, profile.status),
|
||||
_info(l10n.accountLanguage, profile.language),
|
||||
_info(l10n.accountTimezone, profile.timezone),
|
||||
_info(l10n.accountRegion, profile.regionCode),
|
||||
_info(l10n.accountVipLevel, '${profile.vipLevel}'),
|
||||
if (profile.createdAt != null)
|
||||
_info(
|
||||
l10n.accountCreatedAt,
|
||||
formatLocalizedDateTime(context, profile.createdAt!),
|
||||
),
|
||||
if (profile.lastLoginAt != null)
|
||||
_info(
|
||||
l10n.accountLastLogin,
|
||||
formatLocalizedDateTime(context, profile.lastLoginAt!),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _info(String label, String value) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _pickBirthday() async {
|
||||
final value = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime(2000),
|
||||
firstDate: DateTime(1900),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (value != null && mounted) {
|
||||
setState(() => _birthday = _formatDate(value));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final ok = await _logic.save(
|
||||
nickname: _nickname.text,
|
||||
gender: _gender,
|
||||
birthday: _birthday,
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.accountSaved)),
|
||||
);
|
||||
}
|
||||
|
||||
String _knownGender(String value) =>
|
||||
const {'male', 'female', 'unknown'}.contains(value) ? value : 'unknown';
|
||||
}
|
||||
|
||||
String _formatDate(DateTime value) =>
|
||||
'${value.year.toString().padLeft(4, '0')}-'
|
||||
'${value.month.toString().padLeft(_datePadWidth, '0')}-'
|
||||
'${value.day.toString().padLeft(_datePadWidth, '0')}';
|
||||
|
||||
class _AccountCard extends StatelessWidget {
|
||||
const _AccountCard({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.outlineVariantDark),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import '../watch_records/watch_records_page.dart';
|
||||
import '../favorites/favorites_page.dart';
|
||||
import '../notices/notices_page.dart';
|
||||
import '../downloads/downloads_page.dart';
|
||||
import '../account/account_entry_card.dart';
|
||||
|
||||
class MyPage extends StatefulWidget {
|
||||
const MyPage({
|
||||
@@ -198,10 +199,15 @@ class _MyPageState extends State<MyPage> {
|
||||
|
||||
List<Widget> _accountPage(AppLocalizations l10n) => [
|
||||
if (widget.signedIn) _signedInRow(l10n) else _guestRow(l10n),
|
||||
if (widget.signedIn) ...[const SizedBox(height: 18), _wallet(l10n)],
|
||||
const SizedBox(height: 14),
|
||||
if (widget.signedIn) ...[
|
||||
const SizedBox(height: 8),
|
||||
_wallet(l10n),
|
||||
const SizedBox(height: 4),
|
||||
const AccountEntryCard(),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
_menuCard(l10n),
|
||||
const SizedBox(height: 18),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.preferencesHeading,
|
||||
style: const TextStyle(
|
||||
@@ -409,6 +415,8 @@ class _MyPageState extends State<MyPage> {
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: const Icon(
|
||||
Icons.card_giftcard_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
@@ -422,6 +430,8 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.outlineVariantDark),
|
||||
ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
key: const Key('mine-notices'),
|
||||
leading: const Icon(
|
||||
Icons.notifications_none_rounded,
|
||||
@@ -435,6 +445,8 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.outlineVariantDark),
|
||||
ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: const Icon(
|
||||
Icons.history_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
@@ -447,6 +459,8 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.outlineVariantDark),
|
||||
ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: const Icon(
|
||||
Icons.favorite_outline_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
@@ -459,6 +473,8 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.outlineVariantDark),
|
||||
ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: const Icon(
|
||||
Icons.download_outlined,
|
||||
color: AppColors.blueSoft,
|
||||
@@ -471,6 +487,8 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.outlineVariantDark),
|
||||
ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: const Icon(
|
||||
Icons.settings_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
@@ -886,11 +904,7 @@ class _NavItem extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 21,
|
||||
color: selected ? _accent : AppColors.muted,
|
||||
),
|
||||
Icon(icon, size: 21, color: selected ? _accent : AppColors.muted),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/account_profile.dart';
|
||||
import 'package:fruitshorts/core/data/remote/account_api.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
void main() {
|
||||
String envelope(Object? data, {int code = 0, String message = 'ok'}) =>
|
||||
jsonEncode({'code': code, 'message': message, 'data': data});
|
||||
|
||||
AccountApi apiWith(FakeHttpAdapter adapter) =>
|
||||
AccountApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter));
|
||||
|
||||
test('should_get_profile_and_parse_numeric_vip_level', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
envelope({
|
||||
'uid': 'U-K19',
|
||||
'nickname': 'Nadia',
|
||||
'avatarUrl': 'https://cdn/avatar.png',
|
||||
'gender': 'female',
|
||||
'countryCode': 'ID',
|
||||
'regionCode': 'JK',
|
||||
'language': 'id',
|
||||
'timezone': 'Asia/Jakarta',
|
||||
'currency': 'IDR',
|
||||
'status': 'normal',
|
||||
'maskedTarget': 'n***@example.com',
|
||||
'createdAt': '2026-10-19T08:00:00',
|
||||
'lastLoginAt': '2026-10-19T09:00:00',
|
||||
'vipLevel': 2,
|
||||
'vipExpireAt': null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
final profile = await apiWith(adapter).profile();
|
||||
|
||||
expect(adapter.requests.single.method, 'GET');
|
||||
expect(adapter.requests.single.uri.path, '/app/me');
|
||||
expect(profile.uid, 'U-K19');
|
||||
expect(profile.nickname, 'Nadia');
|
||||
expect(profile.vipLevel, 2);
|
||||
expect(profile.createdAt, DateTime(2026, 10, 19, 8));
|
||||
});
|
||||
|
||||
test('should_send_only_changed_profile_fields_when_updating', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
envelope({'uid': 'U-K19', 'nickname': 'Nadia Baru'}),
|
||||
),
|
||||
);
|
||||
|
||||
final profile = await apiWith(adapter).updateProfile(
|
||||
const AccountProfileUpdate(
|
||||
nickname: 'Nadia Baru',
|
||||
gender: 'female',
|
||||
birthday: '1999-01-02',
|
||||
),
|
||||
);
|
||||
|
||||
expect(adapter.requests.single.method, 'PUT');
|
||||
expect(adapter.requests.single.uri.path, '/app/me');
|
||||
expect(adapter.requests.single.data, {
|
||||
'nickname': 'Nadia Baru',
|
||||
'gender': 'female',
|
||||
'birthday': '1999-01-02',
|
||||
});
|
||||
expect(profile.nickname, 'Nadia Baru');
|
||||
});
|
||||
|
||||
test('should_parse_device_ids_from_strings_and_numbers', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
envelope([
|
||||
{
|
||||
'id': '2111012753262120962',
|
||||
'userId': '2111012753262120000',
|
||||
'deviceId': 'd-device-1234',
|
||||
'platform': 'IOS',
|
||||
'deviceModel': 'iPhone 15',
|
||||
'firstSeenAt': '2026-10-18T08:00:00',
|
||||
'lastSeenAt': '2026-10-19T09:00:00',
|
||||
'status': 'active',
|
||||
'deleted': 0,
|
||||
},
|
||||
{'id': 7, 'deviceId': 'd-android-5678', 'platform': 'ANDROID'},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
final devices = await apiWith(adapter).devices();
|
||||
|
||||
expect(adapter.requests.single.uri.path, '/app/me/devices');
|
||||
expect(devices.first.id, '2111012753262120962');
|
||||
expect(devices.first.lastSeenAt, DateTime(2026, 10, 19, 9));
|
||||
expect(devices.last.id, '7');
|
||||
expect(devices.last.deviceModel, '');
|
||||
});
|
||||
|
||||
test('should_parse_export_counts_and_keep_complete_json', () async {
|
||||
final data = {
|
||||
'profile': {'uid': 'U-K19', 'nickname': 'Nadia'},
|
||||
'credentials': [
|
||||
{'type': 'EMAIL', 'masked': 'n***@example.com', 'verified': true},
|
||||
],
|
||||
'consents': [
|
||||
{'scene': 'privacy', 'version': '2026-10-01'},
|
||||
],
|
||||
'devices': [
|
||||
{'id': '9', 'deviceId': 'd-device-1234', 'status': 'active'},
|
||||
],
|
||||
};
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(envelope(data)),
|
||||
);
|
||||
|
||||
final export = await apiWith(adapter).export();
|
||||
|
||||
expect(adapter.requests.single.uri.path, '/app/me/export');
|
||||
expect(export.profile.nickname, 'Nadia');
|
||||
expect(export.credentials.single.masked, 'n***@example.com');
|
||||
expect(export.credentials.single.verified, isTrue);
|
||||
expect(export.consentCount, 1);
|
||||
expect(export.deviceCount, 1);
|
||||
expect(export.rawJson, contains('"credentials"'));
|
||||
});
|
||||
|
||||
test('should_request_deactivation_with_reason_and_parse_schedule', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(envelope('2026-11-03T09:30:00')),
|
||||
);
|
||||
|
||||
final scheduledAt = await apiWith(adapter).deactivate(reason: '不再使用');
|
||||
|
||||
expect(adapter.requests.single.method, 'POST');
|
||||
expect(adapter.requests.single.uri.path, '/app/me/deactivate');
|
||||
expect(adapter.requests.single.data, {'reason': '不再使用'});
|
||||
expect(scheduledAt, DateTime(2026, 11, 3, 9, 30));
|
||||
});
|
||||
|
||||
test('should_cancel_deactivation_with_post_and_no_body', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(envelope(null)),
|
||||
);
|
||||
|
||||
await apiWith(adapter).cancelDeactivation();
|
||||
|
||||
expect(adapter.requests.single.method, 'POST');
|
||||
expect(adapter.requests.single.uri.path, '/app/me/deactivate/cancel');
|
||||
expect(adapter.requests.single.data, isNull);
|
||||
});
|
||||
|
||||
test('should_keep_backend_message_when_cancel_fails', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async =>
|
||||
FakeHttpResponse(envelope(null, code: 100009, message: '没有待处理的注销申请')),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
apiWith(adapter).cancelDeactivation(),
|
||||
throwsA(
|
||||
isA<ApiException>()
|
||||
.having((error) => error.code, 'code', 100009)
|
||||
.having((error) => error.message, 'message', '没有待处理的注销申请'),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,147 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/auth_session.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/account_repository.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/account/account_deactivate_page.dart';
|
||||
import 'package:fruitshorts/pages/account/account_devices_page.dart';
|
||||
import 'package:fruitshorts/pages/account/account_export_page.dart';
|
||||
import 'package:fruitshorts/pages/account/account_profile_page.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import '../support/fake_account_repository.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_fill_profile_and_save_nickname', (tester) async {
|
||||
final repository = FakeAccountRepository();
|
||||
final auth = await _signedInAuth();
|
||||
await _pumpPage(
|
||||
tester,
|
||||
AccountProfilePage(),
|
||||
repository: repository,
|
||||
auth: auth,
|
||||
);
|
||||
|
||||
final nickname = find.byKey(const Key('account-nickname'));
|
||||
expect(tester.widget<TextField>(nickname).controller?.text, 'Nadia');
|
||||
await tester.enterText(nickname, 'Nadia Baru');
|
||||
await tester.tap(find.byKey(const Key('account-profile-save')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.lastUpdate?.nickname, 'Nadia Baru');
|
||||
});
|
||||
|
||||
testWidgets('should_show_device_empty_state_when_list_is_empty', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = FakeAccountRepository();
|
||||
final auth = await _signedInAuth();
|
||||
await _pumpPage(
|
||||
tester,
|
||||
AccountDevicesPage(),
|
||||
repository: repository,
|
||||
auth: auth,
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('account-devices-empty')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_confirm_before_deactivation_and_show_schedule', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = FakeAccountRepository();
|
||||
final auth = await _signedInAuth();
|
||||
await _pumpPage(
|
||||
tester,
|
||||
AccountDeactivatePage(),
|
||||
repository: repository,
|
||||
auth: auth,
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('account-deactivate-reason')),
|
||||
'不再使用',
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('account-deactivate-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('account-deactivate-confirm')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('account-deactivate-confirm')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.lastDeactivateReason, '不再使用');
|
||||
expect(
|
||||
find.byKey(const Key('account-deactivate-scheduled')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(auth.isSignedIn, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('should_retry_export_after_failure', (tester) async {
|
||||
final repository = FakeAccountRepository()..failExport = true;
|
||||
final auth = await _signedInAuth();
|
||||
await _pumpPage(
|
||||
tester,
|
||||
AccountExportPage(),
|
||||
repository: repository,
|
||||
auth: auth,
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('account-export-error')), findsOneWidget);
|
||||
repository.failExport = false;
|
||||
await tester.tap(find.byKey(const Key('account-export-retry')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('account-export-content')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Future<AuthController> _signedInAuth() async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
await AuthSessionStore().save(
|
||||
const AuthSession(
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
user: AuthUser(uid: 'U-K19', nickname: 'Nadia'),
|
||||
),
|
||||
);
|
||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081');
|
||||
final auth = AuthController(AuthApi(client), AuthSessionStore());
|
||||
await auth.load();
|
||||
return auth;
|
||||
}
|
||||
|
||||
Future<void> _pumpPage(
|
||||
WidgetTester tester,
|
||||
Widget page, {
|
||||
required FakeAccountRepository repository,
|
||||
required AuthController auth,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<AccountRepository>.value(value: repository),
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Theme(data: ThemeData.dark(), child: page),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import 'package:fruitshorts/core/data/models/auth_session.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
void main() {
|
||||
test('should_update_profile_summary_and_keep_tokens_when_applied', () async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {
|
||||
'accessToken': 'access-1',
|
||||
'refreshToken': 'refresh-1',
|
||||
'expiresIn': 7200,
|
||||
'user': {'uid': 'U-OLD', 'nickname': 'Old', 'avatarUrl': 'old.png'},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
late AuthController auth;
|
||||
final client = ApiClient(
|
||||
baseUrl: 'http://127.0.0.1:8081',
|
||||
adapter: adapter,
|
||||
accessTokenReader: () => auth.accessToken,
|
||||
tokenRefresher: () => auth.refresh(),
|
||||
);
|
||||
auth = AuthController(AuthApi(client), AuthSessionStore());
|
||||
await auth.register(email: 'a@b.com', password: 'abcd1234');
|
||||
|
||||
await auth.applyProfile(
|
||||
const AuthUser(
|
||||
uid: 'U-K19',
|
||||
nickname: 'Nadia',
|
||||
avatarUrl: 'avatar-k19.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(auth.accessToken, 'access-1');
|
||||
expect(auth.user?.uid, 'U-K19');
|
||||
expect(auth.user?.nickname, 'Nadia');
|
||||
expect(auth.user?.avatarUrl, 'avatar-k19.png');
|
||||
final saved = await AuthSessionStore().read();
|
||||
expect(saved?.refreshToken, 'refresh-1');
|
||||
expect(saved?.user.nickname, 'Nadia');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/data/models/auth_session.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import '../support/fake_account_repository.dart';
|
||||
import '../support/fake_catalog.dart';
|
||||
import '../support/fake_category.dart';
|
||||
import '../support/fake_interaction_repositories.dart';
|
||||
import '../support/fake_notice_repository.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_hide_account_entries_when_signed_out', (tester) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('mine-account-card')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_show_four_account_entries_when_signed_in', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
await AuthSessionStore().save(
|
||||
const AuthSession(
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
user: AuthUser(uid: 'U-K19', nickname: 'Nadia'),
|
||||
),
|
||||
);
|
||||
await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('mine-account-card')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-account-profile')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-account-devices')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-account-export')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-account-deactivate')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpApp(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final catalog = FakeCatalogRepository();
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(
|
||||
playerFactory: createWidgetPlayer,
|
||||
catalogRepository: catalog,
|
||||
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||
favoriteRepository: InMemoryFavoriteRepository(),
|
||||
playbackRepository: FakePlaybackRepository(),
|
||||
commentRepository: FakeCommentRepository(),
|
||||
interactionRepository: FakeInteractionRepository(),
|
||||
noticeRepository: FakeNoticeRepository(),
|
||||
accountRepository: FakeAccountRepository(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
@@ -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;
|
||||
final List<AccountDevice> _devices;
|
||||
final 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