diff --git a/lib/app.dart b/lib/app.dart index f9cacaa..ac1fc0d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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 { 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 { Provider.value(value: _commentRepository), Provider.value(value: _interactionRepository), Provider.value(value: _noticeRepository), + Provider.value(value: _accountRepository), Provider.value(value: widget.haptics), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), diff --git a/lib/core/data/repositories/account_repository.dart b/lib/core/data/repositories/account_repository.dart index 7bc9cfe..c7d029c 100644 --- a/lib/core/data/repositories/account_repository.dart +++ b/lib/core/data/repositories/account_repository.dart @@ -40,7 +40,8 @@ class HttpAccountRepository implements AccountRepository { Future export() => _api.export(); @override - Future deactivate(String reason) => _api.deactivate(reason: reason); + Future deactivate(String reason) => + _api.deactivate(reason: reason); @override Future cancelDeactivation() => _api.cancelDeactivation(); diff --git a/lib/core/data/services/auth_controller.dart b/lib/core/data/services/auth_controller.dart index 0df92e4..32713ba 100644 --- a/lib/core/data/services/auth_controller.dart +++ b/lib/core/data/services/auth_controller.dart @@ -34,7 +34,10 @@ class AuthController extends ChangeNotifier { notifyListeners(); } - Future register({required String email, required String password}) async { + Future register({ + required String email, + required String password, + }) async { final session = await _api.register( email: email, password: password, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fd92349..3dbf4e9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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." } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 8b5f4eb..3dd9f07 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 66fe23d..00faecc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index af33553..99f84c8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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.'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index f969a29..1bef0e3 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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.'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 3430dc1..2172b39 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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 => '注销申请撤销失败。'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 058edff..052f09c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -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": "注销申请撤销失败。" } diff --git a/lib/pages/account/account_bindings.dart b/lib/pages/account/account_bindings.dart new file mode 100644 index 0000000..117da87 --- /dev/null +++ b/lib/pages/account/account_bindings.dart @@ -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 Function(String text) copyText, + ) => AccountExportLogic(repository, copyText: copyText); +} + +class AccountDeactivateBinding { + AccountDeactivateLogic create( + AccountRepository repository, + AuthController auth, + ) => AccountDeactivateLogic(repository, clearSession: auth.clear); +} diff --git a/lib/pages/account/account_deactivate_logic.dart b/lib/pages/account/account_deactivate_logic.dart index bcb9d7e..ccead0b 100644 --- a/lib/pages/account/account_deactivate_logic.dart +++ b/lib/pages/account/account_deactivate_logic.dart @@ -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; diff --git a/lib/pages/account/account_deactivate_page.dart b/lib/pages/account/account_deactivate_page.dart new file mode 100644 index 0000000..1f54928 --- /dev/null +++ b/lib/pages/account/account_deactivate_page.dart @@ -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 createState() => _AccountDeactivatePageState(); +} + +class _AccountDeactivatePageState extends State { + late final AccountDeactivateLogic _logic; + final _reason = TextEditingController(); + + @override + void initState() { + super.initState(); + _logic = AccountDeactivateBinding().create( + context.read(), + context.read(), + ); + } + + @override + void dispose() { + _logic.dispose(); + _reason.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().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 _confirm() async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + 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 _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, + }; + } +} diff --git a/lib/pages/account/account_devices_page.dart b/lib/pages/account/account_devices_page.dart new file mode 100644 index 0000000..9be51e7 --- /dev/null +++ b/lib/pages/account/account_devices_page.dart @@ -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 createState() => _AccountDevicesPageState(); +} + +class _AccountDevicesPageState extends State { + late final AccountDevicesLogic _logic; + + @override + void initState() { + super.initState(); + _logic = AccountDevicesBinding().create(context.read()); + unawaited(_logic.load()); + } + + @override + void dispose() { + _logic.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().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'; +} diff --git a/lib/pages/account/account_export_page.dart b/lib/pages/account/account_export_page.dart new file mode 100644 index 0000000..4d4568a --- /dev/null +++ b/lib/pages/account/account_export_page.dart @@ -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 createState() => _AccountExportPageState(); +} + +class _AccountExportPageState extends State { + late final AccountExportLogic _logic; + + @override + void initState() { + super.initState(); + _logic = AccountExportBinding().create( + context.read(), + (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().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 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 _copy() async { + final ok = await _logic.copy(); + if (!ok || !mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.accountCopied)), + ); + } +} diff --git a/lib/pages/account/account_profile_page.dart b/lib/pages/account/account_profile_page.dart new file mode 100644 index 0000000..674b89e --- /dev/null +++ b/lib/pages/account/account_profile_page.dart @@ -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 createState() => _AccountProfilePageState(); +} + +class _AccountProfilePageState extends State { + 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(), + context.read(), + )..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().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( + 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 _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 _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, + ); +} diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 3af591b..3b84bc0 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -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 { List _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 { ); } + 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(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( diff --git a/test/account/account_api_test.dart b/test/account/account_api_test.dart index 5bb1e97..60eaadf 100644 --- a/test/account/account_api_test.dart +++ b/test/account/account_api_test.dart @@ -13,9 +13,8 @@ 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), - ); + 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( @@ -90,11 +89,7 @@ void main() { 'status': 'active', 'deleted': 0, }, - { - 'id': 7, - 'deviceId': 'd-android-5678', - 'platform': 'ANDROID', - }, + {'id': 7, 'deviceId': 'd-android-5678', 'platform': 'ANDROID'}, ]), ), ); @@ -138,14 +133,10 @@ void main() { test('should_request_deactivation_with_reason_and_parse_schedule', () async { final adapter = FakeHttpAdapter( - (_) async => FakeHttpResponse( - envelope('2026-11-03T09:30:00'), - ), + (_) async => FakeHttpResponse(envelope('2026-11-03T09:30:00')), ); - final scheduledAt = await apiWith( - adapter, - ).deactivate(reason: '不再使用'); + final scheduledAt = await apiWith(adapter).deactivate(reason: '不再使用'); expect(adapter.requests.single.method, 'POST'); expect(adapter.requests.single.uri.path, '/app/me/deactivate'); @@ -161,18 +152,14 @@ void main() { await apiWith(adapter).cancelDeactivation(); expect(adapter.requests.single.method, 'POST'); - expect( - adapter.requests.single.uri.path, - '/app/me/deactivate/cancel', - ); + 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: '没有待处理的注销申请'), - ), + (_) async => + FakeHttpResponse(envelope(null, code: 100009, message: '没有待处理的注销申请')), ); await expectLater( diff --git a/test/account/account_logic_test.dart b/test/account/account_logic_test.dart index 47757a6..49d1db9 100644 --- a/test/account/account_logic_test.dart +++ b/test/account/account_logic_test.dart @@ -70,28 +70,28 @@ void main() { 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, - ); + 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('不再使用'); + final ok = await logic.request('不再使用'); - expect(ok, isTrue); - expect(repository.lastDeactivateReason, '不再使用'); - expect(logic.scheduledAt, DateTime(2026, 11, 3, 9, 30)); - expect(cleared, isTrue); - }); + 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 logic = AccountDeactivateLogic(repository, clearSession: () async {}); final ok = await logic.cancel(); diff --git a/test/account/account_pages_test.dart b/test/account/account_pages_test.dart new file mode 100644 index 0000000..b023326 --- /dev/null +++ b/test/account/account_pages_test.dart @@ -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(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 _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 _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.value(value: repository), + ChangeNotifierProvider.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(); +} diff --git a/test/account/auth_profile_sync_test.dart b/test/account/auth_profile_sync_test.dart index d732c1a..85519cb 100644 --- a/test/account/auth_profile_sync_test.dart +++ b/test/account/auth_profile_sync_test.dart @@ -27,11 +27,7 @@ void main() { 'accessToken': 'access-1', 'refreshToken': 'refresh-1', 'expiresIn': 7200, - 'user': { - 'uid': 'U-OLD', - 'nickname': 'Old', - 'avatarUrl': 'old.png', - }, + 'user': {'uid': 'U-OLD', 'nickname': 'Old', 'avatarUrl': 'old.png'}, }, }), ), diff --git a/test/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart new file mode 100644 index 0000000..7097af0 --- /dev/null +++ b/test/account/mine_account_entry_test.dart @@ -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 _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(); +} diff --git a/test/support/fake_account_repository.dart b/test/support/fake_account_repository.dart index d23f6e6..039e0a3 100644 --- a/test/support/fake_account_repository.dart +++ b/test/support/fake_account_repository.dart @@ -55,8 +55,8 @@ class FakeAccountRepository implements AccountRepository { ); AccountProfile _profile; - List _devices; - AccountExport _export; + final List _devices; + final AccountExport _export; AccountProfileUpdate? lastUpdate; String? lastDeactivateReason; int profileCalls = 0;