新增:资料页真实化 + 编辑页——/me 读写、PATCH 三态、获赞与作品统计(T3.5-08)
资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,176 行硬编码 demo(「萌宠新手(豆豆家长)」/ 24 / 1.8k / 2)退役。 - `PatchField<T>` 承载契约 v1.4.0 的 PATCH 三态(absent 不落键 / clear 落 显式 null / value 落值)。三态必须由类型承载而非 `T?` 加约定:把「不改」 也编码成 null,用户只改昵称就会连头像一起被服务端清掉。 - `UserProfile` 补 nickname / avatarUrl,展示回退 `nickname ?? username` **做在客户端展示层**——服务端 /me 刻意返回 DB 原值,编辑页因此只用原值 预填,避免把展示约定固化成真实昵称。 - `ProfileController`:`/me` 主链路四态 + 两块统计独立三态(统计失败只降级 这一块,不为两个数字丢掉整页);登出 reset 防跨账号泄漏。 - 编辑页维护三态意图而非「当前值整体提交」:昵称 / 头像各有显式清除入口, 未改字段的键根本不进 JSON;空 patch 直接短路(服务端对空 patch 答 400)。 - 昵称校验按**码点**计 1~32 且先 btrim,与 `ck_users_nickname` 对齐; 纯空白是校验失败而非隐式清空。 - `MediaUploader` 的 purpose 参数化 + 复用其六态编排的 `AvatarUploadSheet` (单图、预览确认后才交付 ready assetId,孤儿防护不变)。 - 修复 `/api/v1/me` 端口线路:该端点由 user 服务(:8082)提供,`ApiAuthRepository` 此前只挂 auth(:8081)会得到 404。此前无人消费 me(),故这条错线一直没被 触发;本单是第一个真实消费者,桌面实测即暴露。 测试 526 → 588(+62)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
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: "<ready assetId>"` |
|
||||
/// | 点「清除头像」 | `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<ProfileEditPage> createState() => _ProfileEditPageState();
|
||||
}
|
||||
|
||||
class _ProfileEditPageState extends State<ProfileEditPage> {
|
||||
late final TextEditingController _nickname;
|
||||
|
||||
/// 页面进入时的资料快照:差量比对的基准(服务端回显的权威值)。
|
||||
late final UserProfile _initial;
|
||||
|
||||
/// 昵称的三态意图:null 表示「跟随输入框与初值的比对结果」,
|
||||
/// 非 null 表示用户已显式点过「清除昵称」。
|
||||
bool _nicknameCleared = false;
|
||||
|
||||
/// 头像的三态意图(absent / clear / 新 assetId)。
|
||||
PatchField<String> _avatarIntent = const PatchField<String>.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<String> get _nicknamePatch {
|
||||
if (_nicknameCleared) {
|
||||
// 本来就没有昵称时「清除」是空操作,不落键(避免制造空 patch 之外
|
||||
// 的无意义写入)。
|
||||
return _initial.nickname == null
|
||||
? const PatchField<String>.absent()
|
||||
: const PatchField<String>.clear();
|
||||
}
|
||||
final trimmed = _nickname.text.trim();
|
||||
if (trimmed.isEmpty) return const PatchField<String>.absent();
|
||||
if (trimmed == _initial.nickname) return const PatchField<String>.absent();
|
||||
return PatchField<String>.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<void> _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<String>.value(assetId);
|
||||
// 预览沿用上传器给的可访问 URL?—— 没有:complete 响应的 url 与
|
||||
// /me 的 avatarUrl 同为预签名,但本页不缓存它,改为保存后由服务端
|
||||
// 回显。上传成功到保存之间以「已选择新头像」文案示意。
|
||||
_pendingAvatarUrl = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _clearAvatar() {
|
||||
setState(() {
|
||||
_avatarIntent = const PatchField<String>.clear();
|
||||
_pendingAvatarUrl = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _clearNickname() {
|
||||
setState(() {
|
||||
_nicknameCleared = true;
|
||||
_nicknameError = null;
|
||||
_nickname.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user