feat(account): 增加账号中心页面与入口
This commit is contained in:
1 parent
c68f02449f
commit
da095115d4
23 files changed
+2343
-60
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/account_device.dart';
|
||||
import '../../core/data/repositories/account_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/formatting/localized_date_time.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'account_bindings.dart';
|
||||
import 'account_devices_logic.dart';
|
||||
|
||||
const _maskVisible = 4;
|
||||
|
||||
/// 登录设备只读列表页。
|
||||
class AccountDevicesPage extends StatefulWidget {
|
||||
const AccountDevicesPage({super.key});
|
||||
|
||||
@override
|
||||
State<AccountDevicesPage> createState() => _AccountDevicesPageState();
|
||||
}
|
||||
|
||||
class _AccountDevicesPageState extends State<AccountDevicesPage> {
|
||||
late final AccountDevicesLogic _logic;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_logic = AccountDevicesBinding().create(context.read<AccountRepository>());
|
||||
unawaited(_logic.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logic.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: Scaffold(
|
||||
key: const Key('account-devices-page'),
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
title: Text(l10n.accountDevices),
|
||||
),
|
||||
body: signedIn
|
||||
? ListenableBuilder(
|
||||
listenable: _logic,
|
||||
builder: (context, _) => _body(l10n),
|
||||
)
|
||||
: const AuthGatePanel(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(AppLocalizations l10n) {
|
||||
if (_logic.loading && _logic.devices.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_logic.error != null && _logic.devices.isEmpty) {
|
||||
return EmptyState(
|
||||
key: const Key('account-devices-error'),
|
||||
icon: Icons.cloud_off_rounded,
|
||||
title: errorText(l10n, _logic.error, l10n.accountDevicesLoadFailed),
|
||||
actionLabel: l10n.retry,
|
||||
actionKey: const Key('account-devices-retry'),
|
||||
onAction: _logic.load,
|
||||
);
|
||||
}
|
||||
if (_logic.devices.isEmpty) {
|
||||
return EmptyState(
|
||||
key: const Key('account-devices-empty'),
|
||||
icon: Icons.devices_other_rounded,
|
||||
title: l10n.accountDevicesEmpty,
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
itemCount: _logic.devices.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, index) =>
|
||||
_DeviceTile(device: _logic.devices[index], l10n: l10n),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceTile extends StatelessWidget {
|
||||
const _DeviceTile({required this.device, required this.l10n});
|
||||
|
||||
final AccountDevice device;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: ValueKey('account-device-${device.id}'),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.outlineVariantDark),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.phone_android_rounded,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
device.deviceModel.isEmpty
|
||||
? device.platform
|
||||
: device.deviceModel,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
_StatusChip(status: device.status, l10n: l10n),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_line(l10n.accountPlatform, device.platform),
|
||||
_line(l10n.accountDeviceId, _maskDeviceId(device.deviceId)),
|
||||
if (device.firstSeenAt != null)
|
||||
_line(
|
||||
l10n.accountFirstSeen,
|
||||
formatLocalizedDateTime(context, device.firstSeenAt!),
|
||||
),
|
||||
if (device.lastSeenAt != null)
|
||||
_line(
|
||||
l10n.accountLastActive,
|
||||
formatLocalizedDateTime(context, device.lastSeenAt!),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _line(String label, String value) => Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.status, required this.l10n});
|
||||
|
||||
final String status;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = status.toLowerCase() == 'active';
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? scheme.tertiary.withValues(alpha: 0.16)
|
||||
: AppColors.raised,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
active ? l10n.accountDeviceActive : l10n.accountDeviceInactive,
|
||||
style: TextStyle(
|
||||
color: active ? scheme.tertiary : AppColors.muted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _maskDeviceId(String value) {
|
||||
if (value.length <= _maskVisible * 2) return value;
|
||||
final head = value.substring(0, _maskVisible);
|
||||
final tail = value.substring(value.length - _maskVisible);
|
||||
return '$head****$tail';
|
||||
}
|
||||
@@ -0,0 +1,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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user