feat(account): 增加账号中心页面与入口

This commit is contained in:
Zhou Weidong committed 2026-10-19 09:50:23 +08:00
1 parent c68f02449f
commit da095115d4
23 files changed
+2343 -60

No files matched your search

+8
View File
@@ -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(),
@@ -40,7 +40,8 @@ class HttpAccountRepository implements AccountRepository {
Future<AccountExport> export() => _api.export();
@override
Future<DateTime?> deactivate(String reason) => _api.deactivate(reason: reason);
Future<DateTime?> deactivate(String reason) =>
_api.deactivate(reason: reason);
@override
Future<void> cancelDeactivation() => _api.cancelDeactivation();
+4 -1
View File
@@ -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,
+62 -1
View File
@@ -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
View File
@@ -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."
}
+336
View File
@@ -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
+173
View File
@@ -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.';
}
+173
View File
@@ -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.';
}
+171
View File
@@ -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
View File
@@ -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": "注销申请撤销失败。"
}
+40
View File
@@ -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);
}
@@ -7,10 +7,7 @@ import '../../core/data/repositories/account_repository.dart';
/// 注销申请与撤销状态。
class AccountDeactivateLogic extends ChangeNotifier {
AccountDeactivateLogic(
this._repository, {
required this.clearSession,
});
AccountDeactivateLogic(this._repository, {required this.clearSession});
static const maxReasonLength = 200;
@@ -0,0 +1,193 @@
// 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) {
final errorColor = Theme.of(context).colorScheme.error;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
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),
),
),
if (_logic.scheduledAt != null) ...[
const SizedBox(height: 14),
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!),
),
),
),
] else ...[
const SizedBox(height: 16),
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)
Text(
_message(l10n, _logic.error, l10n.accountDeactivateFailed),
style: TextStyle(color: errorColor, fontSize: 12),
),
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),
),
],
const SizedBox(height: 14),
OutlinedButton(
key: const Key('account-deactivate-cancel'),
onPressed: _logic.cancelling ? null : _cancel,
child: Text(l10n.accountCancelDeactivation),
),
if (_logic.actionMessage != null) ...[
const SizedBox(height: 8),
Text(
_message(l10n, _logic.actionMessage, l10n.accountCancelFailed),
style: TextStyle(color: errorColor, 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,
};
}
}
+216
View File
@@ -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';
}
+176
View File
@@ -0,0 +1,176 @@
// 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/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: [
_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),
),
],
),
const SizedBox(height: 12),
_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}'),
],
),
const SizedBox(height: 16),
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)),
);
}
}
+339
View File
@@ -0,0 +1,339 @@
// 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) {
final scheme = Theme.of(context).colorScheme;
return _AccountCard(
child: Column(
children: [
TextField(
key: const Key('account-nickname'),
controller: _nickname,
maxLength: AccountProfileLogic.maxNicknameLength,
decoration: InputDecoration(
labelText: l10n.accountNickname,
errorText: _logic.fieldError == null
? null
: l10n.accountNicknameInvalid,
),
),
const SizedBox(height: _fieldGap),
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);
},
),
const SizedBox(height: _fieldGap),
OutlinedButton.icon(
key: const Key('account-birthday'),
onPressed: _pickBirthday,
icon: const Icon(Icons.cake_outlined),
label: Text(_birthday ?? l10n.accountSelectBirthday),
),
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.accountBirthdayHint,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
),
if (_logic.error != null) ...[
const SizedBox(height: 8),
Text(
errorText(l10n, _logic.error, l10n.accountProfileSaveFailed),
style: TextStyle(color: scheme.error, fontSize: 12),
),
],
const SizedBox(height: 16),
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,
);
}
+71 -6
View File
@@ -24,6 +24,10 @@ 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_profile_page.dart';
import '../account/account_devices_page.dart';
import '../account/account_export_page.dart';
import '../account/account_deactivate_page.dart';
class MyPage extends StatefulWidget {
const MyPage({
@@ -198,7 +202,12 @@ 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)],
if (widget.signedIn) ...[
const SizedBox(height: 18),
_wallet(l10n),
const SizedBox(height: 14),
_accountCard(l10n),
],
const SizedBox(height: 14),
_menuCard(l10n),
const SizedBox(height: 18),
@@ -487,6 +496,66 @@ class _MyPageState extends State<MyPage> {
);
}
Widget _accountCard(AppLocalizations l10n) {
return Material(
key: const Key('mine-account-card'),
color: AppColors.card,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
ListTile(
key: const Key('mine-account-profile'),
leading: const Icon(
Icons.person_outline_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.accountProfile),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => _openAccount(const AccountProfilePage()),
),
const Divider(height: 1, color: AppColors.outlineVariantDark),
ListTile(
key: const Key('mine-account-devices'),
leading: const Icon(
Icons.devices_other_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.accountDevices),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => _openAccount(const AccountDevicesPage()),
),
const Divider(height: 1, color: AppColors.outlineVariantDark),
ListTile(
key: const Key('mine-account-export'),
leading: const Icon(
Icons.file_download_outlined,
color: AppColors.blueSoft,
),
title: Text(l10n.accountDataExport),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => _openAccount(const AccountExportPage()),
),
const Divider(height: 1, color: AppColors.outlineVariantDark),
ListTile(
key: const Key('mine-account-deactivate'),
leading: const Icon(
Icons.person_remove_outlined,
color: AppColors.blueSoft,
),
title: Text(l10n.accountDeactivate),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => _openAccount(const AccountDeactivatePage()),
),
],
),
);
}
void _openAccount(Widget page) {
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
}
Widget _library(
AppLocalizations l10n,
Duration position,
@@ -886,11 +955,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(