// 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_dimens.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.light(), child: Scaffold( key: const Key('account-profile-page'), backgroundColor: AppColors.ink, appBar: AppBar( backgroundColor: AppColors.ink, foregroundColor: AppColors.onPrimary, title: Text(l10n.accountProfile), ), body: SafeArea( top: false, child: 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: AppDimens.pageTitle, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( '${l10n.accountUid}: ${profile.uid}', style: const TextStyle( color: AppColors.muted, fontSize: AppDimens.caption, ), ), ], ), ), ], ); } Widget _editableForm(AppLocalizations l10n) { return _AccountCard( child: Column( children: [ _nicknameField(l10n), const SizedBox(height: _fieldGap), _genderField(l10n), const SizedBox(height: _fieldGap), _birthdayField(l10n), if (_logic.error != null) _saveError(l10n), const SizedBox(height: 16), _saveButton(l10n), ], ), ); } Widget _nicknameField(AppLocalizations l10n) => TextField( key: const Key('account-nickname'), controller: _nickname, maxLength: AccountProfileLogic.maxNicknameLength, decoration: InputDecoration( labelText: l10n.accountNickname, errorText: _logic.fieldError == null ? null : l10n.accountNicknameInvalid, ), ); Widget _genderField(AppLocalizations l10n) => DropdownButtonFormField( key: const Key('account-gender'), initialValue: _gender, decoration: InputDecoration(labelText: l10n.accountGender), items: [ DropdownMenuItem(value: 'male', child: Text(l10n.accountMale)), DropdownMenuItem(value: 'female', child: Text(l10n.accountFemale)), DropdownMenuItem( value: 'unknown', child: Text(l10n.accountUnknownGender), ), ], onChanged: (value) { if (value != null) setState(() => _gender = value); }, ); Widget _birthdayField(AppLocalizations l10n) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ OutlinedButton.icon( key: const Key('account-birthday'), onPressed: _pickBirthday, icon: const Icon(Icons.cake_outlined), label: Text(_birthday ?? l10n.accountSelectBirthday), ), const SizedBox(height: 6), Text( l10n.accountBirthdayHint, style: const TextStyle( color: AppColors.muted, fontSize: AppDimens.caption, ), ), ], ); Widget _saveError(AppLocalizations l10n) => Padding( padding: const EdgeInsets.only(top: 8), child: Text( errorText(l10n, _logic.error, l10n.accountProfileSaveFailed), style: TextStyle( color: Theme.of(context).colorScheme.error, fontSize: AppDimens.caption, ), ), ); Widget _saveButton(AppLocalizations l10n) => SizedBox( width: double.infinity, height: 52, child: FilledButton( key: const Key('account-profile-save'), onPressed: _logic.saving ? null : _save, child: _logic.saving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(l10n.save), ), ); Widget _readOnlyCard(AccountProfile profile, AppLocalizations l10n) { return _AccountCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.accountReadOnlyInfo, style: const TextStyle( fontSize: AppDimens.listTitle, 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: AppDimens.caption, ), ), ), Flexible( child: Text( value.isEmpty ? '-' : value, textAlign: TextAlign.right, style: const TextStyle(fontSize: AppDimens.body), ), ), ], ), ); 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(AppDimens.cardPadding), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(AppDimens.cardRadius), border: Border.all(color: AppColors.outlineVariantDark), ), child: child, ); }