设备标识: Android SSAID/ iOS IDFV 原生通道 → Dart DeviceIdProvider 国家解析: 注册/登录从后端 IP → 国家/语言/时区/币种 自动落库 账号资料: 全部本地化展示 (account_display.dart) 我的页: 继续观看卡片点击直接进播放页
338 lines
9.9 KiB
Dart
338 lines
9.9 KiB
Dart
// 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/account_display.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/plain_page_scaffold.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;
|
|
|
|
/// 个人资料编辑页。
|
|
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';
|
|
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: PlainPageScaffold(
|
|
pageKey: const Key('account-profile-page'),
|
|
backKey: const Key('account-profile-back'),
|
|
title: l10n.accountProfile,
|
|
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),
|
|
if (_logic.error != null) _saveError(l10n),
|
|
const SizedBox(height: 16),
|
|
_saveButton(l10n),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _nicknameField(AppLocalizations l10n) => TextField(
|
|
key: const Key('account-nickname'),
|
|
controller: _nickname,
|
|
maxLength: AccountProfileLogic.maxNicknameLength,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.accountNickname,
|
|
errorText: _logic.fieldError == null ? null : l10n.accountNicknameInvalid,
|
|
),
|
|
);
|
|
|
|
Widget _genderField(AppLocalizations l10n) => DropdownButtonFormField<String>(
|
|
key: const Key('account-gender'),
|
|
isExpanded: true,
|
|
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 _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) {
|
|
final localeName = Localizations.localeOf(context).languageCode;
|
|
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,
|
|
countryName(profile.countryCode, localeName),
|
|
),
|
|
_info(l10n.accountCurrency, profile.currency),
|
|
_info(
|
|
l10n.accountStatus,
|
|
accountStatusText(profile.status, localeName),
|
|
),
|
|
_info(
|
|
l10n.accountLanguage,
|
|
languageName(profile.language, localeName),
|
|
),
|
|
_info(
|
|
l10n.accountTimezone,
|
|
timezoneName(profile.timezone, localeName),
|
|
),
|
|
// region 与 country 后端写的是同一个值,重复时不再单列一行
|
|
if (profile.regionCode.isNotEmpty &&
|
|
profile.regionCode != profile.countryCode)
|
|
_info(
|
|
l10n.accountRegion,
|
|
countryName(profile.regionCode, localeName),
|
|
),
|
|
_info(
|
|
l10n.accountVipLevel,
|
|
vipLevelText(profile.vipLevel, localeName),
|
|
),
|
|
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<void> _save() async {
|
|
final ok = await _logic.save(nickname: _nickname.text, gender: _gender);
|
|
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';
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|