import 'package:flutter/material.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart'; import 'package:patbond_flutter/features/auth/auth_models.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/profile/profile_controller.dart'; import 'package:patbond_flutter/features/profile/profile_display.dart'; import 'package:patbond_flutter/widgets/common.dart'; /// 资料编辑页(T3.5-08):昵称 + 头像的唯一写入口。 /// /// **PATCH 三态在本页成形**(契约 v1.4.0 的 `UpdateMeRequest`): /// /// | 用户动作 | 提交表现 | /// | --- | --- | /// | 没碰昵称 | `nickname` **键不出现**(不改) | /// | 改了昵称 | `nickname: "新值"` | /// | 点「清除昵称」 | `nickname: null`(清空) | /// | 没碰头像 | `avatarAssetId` **键不出现** | /// | 传了新头像 | `avatarAssetId: ""` | /// | 点「清除头像」 | `avatarAssetId: null` | /// /// 「不改」与「清空」在 JSON 上是**键缺省 vs 显式 null** 两回事:若把未改 /// 字段也发成 null,用户只改昵称就会连头像一起被清掉。故本页不维护 /// 「当前值 → 直接整体提交」的表单模型,而是维护**三态意图**([PatchField]), /// 与初值比对后只让真正变化的字段落键。 class ProfileEditPage extends StatefulWidget { const ProfileEditPage({ required this.controller, super.key, this.avatarUploaderBuilder, }); final ProfileController controller; /// 头像上传编排器构造口;null 即本次构建未装配上传能力,隐藏头像入口 /// (生产装配恒注入,见 `app.dart`)。 final AvatarUploaderBuilder? avatarUploaderBuilder; @override State createState() => _ProfileEditPageState(); } class _ProfileEditPageState extends State { late final TextEditingController _nickname; /// 页面进入时的资料快照:差量比对的基准(服务端回显的权威值)。 late final UserProfile _initial; /// 昵称的三态意图:null 表示「跟随输入框与初值的比对结果」, /// 非 null 表示用户已显式点过「清除昵称」。 bool _nicknameCleared = false; /// 头像的三态意图(absent / clear / 新 assetId)。 PatchField _avatarIntent = const PatchField.absent(); /// 新上传头像的本地预览(仅本页会话内有效,不落盘)。 String? _pendingAvatarUrl; String? _nicknameError; bool _saving = false; ApiException? _saveError; @override void initState() { super.initState(); _initial = widget.controller.profile!; // **预填只用 DB 原值**,不用 displayName:若把回退出来的 username // 预填进来,用户会以为自己设过昵称,一保存就把展示约定固化成数据 // (服务端 /me 刻意不回退,正是为了留住这个区别)。 _nickname = TextEditingController(text: _initial.nickname ?? ''); _nickname.addListener(_onNicknameChanged); } @override void dispose() { _nickname.dispose(); super.dispose(); } void _onNicknameChanged() { // 每次输入都重建:保存钮的可用性由 `_hasChanges` 现算,不重建就会出现 // 「已经打了字但保存钮还是灰的」。同时用户重新打字即撤销「清除」意图, // 并清掉上一次的字段级错误。 setState(() { if (_nicknameCleared && _nickname.text.isNotEmpty) { _nicknameCleared = false; } _nicknameError = null; }); } /// 昵称三态:显式清除 > 与初值不同即设置 > 否则不改。 PatchField get _nicknamePatch { if (_nicknameCleared) { // 本来就没有昵称时「清除」是空操作,不落键(避免制造空 patch 之外 // 的无意义写入)。 return _initial.nickname == null ? const PatchField.absent() : const PatchField.clear(); } final trimmed = _nickname.text.trim(); if (trimmed.isEmpty) return const PatchField.absent(); if (trimmed == _initial.nickname) return const PatchField.absent(); return PatchField.value(trimmed); } UpdateMeRequest get _request => UpdateMeRequest(nickname: _nicknamePatch, avatarAssetId: _avatarIntent); bool get _hasChanges => !_request.isEmpty; /// 当前应展示的头像:新传的 > 清除意图(无图) > 服务端现值。 String? get _shownAvatarUrl { if (_avatarIntent.isClear) return null; return _pendingAvatarUrl ?? _initial.avatarUrl; } bool get _hasAvatarNow => _shownAvatarUrl != null; Future _pickAvatar() async { final builder = widget.avatarUploaderBuilder; if (builder == null) return; final assetId = await showAvatarUploadSheet( context, builder: builder, purpose: MediaPurpose.userAvatar, ); if (assetId == null || !mounted) return; setState(() { _avatarIntent = PatchField.value(assetId); // 预览沿用上传器给的可访问 URL?—— 没有:complete 响应的 url 与 // /me 的 avatarUrl 同为预签名,但本页不缓存它,改为保存后由服务端 // 回显。上传成功到保存之间以「已选择新头像」文案示意。 _pendingAvatarUrl = null; }); } void _clearAvatar() { setState(() { _avatarIntent = const PatchField.clear(); _pendingAvatarUrl = null; }); } void _clearNickname() { setState(() { _nicknameCleared = true; _nicknameError = null; _nickname.clear(); }); } Future _save() async { // 有内容的昵称才校验:空输入框意为「不改」(清空走显式入口)。 final raw = _nickname.text; if (!_nicknameCleared && raw.trim().isNotEmpty) { final error = validateNickname(raw); if (error != null) { setState(() => _nicknameError = error); return; } } final request = _request; if (request.isEmpty) { // 空 patch 服务端答 400/40000(刻意不静默 200);客户端不去撞这一枪。 Navigator.of(context).pop(false); return; } setState(() { _saving = true; _saveError = null; }); try { await widget.controller.save(request); if (!mounted) return; Navigator.of(context).pop(true); } on ApiException catch (error) { if (!mounted) return; setState(() { _saving = false; _saveError = error; }); } } @override Widget build(BuildContext context) { final canUploadAvatar = widget.avatarUploaderBuilder != null; return Scaffold( appBar: AppBar(title: const Text('编辑资料')), body: ListView( padding: const EdgeInsets.fromLTRB(20, 12, 20, 30), children: [ if (_saveError != null) ...[ InlineErrorBanner(message: profileSaveErrorMessage(_saveError)), const SizedBox(height: 16), ], if (canUploadAvatar) ...[ Center( child: Column( children: [ _AvatarPreview( url: _shownAvatarUrl, pendingAssetId: _avatarIntent.valueOrNull, onTap: _saving ? null : _pickAvatar, ), const SizedBox(height: 10), TextButton.icon( onPressed: _saving ? null : _pickAvatar, icon: const Icon(Icons.photo_camera_outlined, size: 18), label: const Text('更换头像'), ), if (_hasAvatarNow) TextButton( onPressed: _saving ? null : _clearAvatar, child: const Text( '清除头像', style: TextStyle(color: AppColors.errorDark), ), ), if (_avatarIntent.isClear) const Text( '保存后将移除头像', style: TextStyle(color: AppColors.inkSoft, fontSize: 12), ), ], ), ), const SizedBox(height: 20), ], AppTextField( label: '昵称', controller: _nickname, enabled: !_saving, errorText: _nicknameError, // 未设昵称时点明「现在别人看到的是用户名」——展示回退是客户端 // 行为,不写进输入框(否则一保存就变成真昵称)。 helperText: _initial.nickname == null ? '未设置,当前展示为用户名「${_initial.username}」' : '1~32 个字', textInputAction: TextInputAction.done, onSubmitted: (_) => _saving ? null : _save(), ), if (_initial.nickname != null) Align( alignment: Alignment.centerLeft, child: TextButton( onPressed: _saving ? null : _clearNickname, child: const Text( '清除昵称', style: TextStyle(color: AppColors.errorDark), ), ), ), if (_nicknameCleared) const Text( '保存后将清除昵称,展示名回退为用户名', style: TextStyle(color: AppColors.inkSoft, fontSize: 12), ), const SizedBox(height: 24), PrimaryButton( label: '保存', isLoading: _saving, // 无变更时禁用:避免用户以为「点了保存但什么都没发生」。 onPressed: _hasChanges ? _save : null, ), ], ), ); } } /// 编辑页头像预览:有 URL 走 [RemoteImage](缓存 key 已剥签名参数); /// 刚上传但尚未保存时无可用 URL,以「已选择新头像」占位说明。 class _AvatarPreview extends StatelessWidget { const _AvatarPreview({ required this.url, required this.pendingAssetId, this.onTap, }); final String? url; final String? pendingAssetId; final VoidCallback? onTap; @override Widget build(BuildContext context) { final Widget content; if (url != null) { content = RemoteImage( url: url!, width: 96, height: 96, borderRadius: BorderRadius.circular(48), ); } else if (pendingAssetId != null) { content = const ColoredBox( color: AppColors.surfaceTint, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.check_circle, color: AppColors.primary, size: 24), SizedBox(height: 4), Text( '已选择\n新头像', textAlign: TextAlign.center, style: TextStyle(color: AppColors.primaryDark, fontSize: 10), ), ], ), ), ); } else { content = const ColoredBox( color: AppColors.surfaceTint, child: Icon(Icons.person_outline, color: AppColors.muted, size: 40), ); } return Semantics( label: '更换头像', button: onTap != null, child: InkWell( onTap: onTap, customBorder: const CircleBorder(), child: SizedBox(width: 96, height: 96, child: ClipOval(child: content)), ), ); } }