Files
patbond-flutter/lib/features/profile/profile_page.dart
T
lixi a4a97c03c2 新增:资料页真实化 + 编辑页——/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>
2026-09-11 15:37:00 +08:00

386 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/features/profile/profile_display.dart';
import 'package:patbond_flutter/features/profile/profile_edit_page.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
/// 「我的资料」TabT3.5-08 真实化)。
///
/// 头部三项(展示名 / 头像 / 四个数字)自此全部来自服务端:
/// `GET /me`(昵称与头像)+ `GET /me/community-stats`(获赞 / 作品)+
/// `GET /users/{me}/follow-stats`(粉丝 / 关注)。M3 之前的硬编码
/// 「萌宠新手(豆豆家长)」/「24 / 1.8k / 2」已退役。
///
/// 四态:loading(居中转圈)/ error(横幅 + 重试)/ ready。**没有独立空态**
/// ——任何已认证用户都有资料,`/me/community-stats` 契约上「永不 404、空数据
/// 返回 0」,故「新用户什么都没有」的形态就是 ready 态里的一排 0,
/// 而不是另一个页面态。统计块另有独立三态(见 [ProfileStatsPhase])。
class ProfilePage extends StatefulWidget {
const ProfilePage({
required this.appState,
required this.controller,
super.key,
this.avatarUploaderBuilder,
this.onLogout,
});
final AppState appState;
/// 资料与统计数据源(Tab 级单例,`app.dart` 装配注入;首页问候语同源)。
final ProfileController controller;
/// 头像上传编排器构造口(透传编辑页)。
final AvatarUploaderBuilder? avatarUploaderBuilder;
/// 真实退出登录入口;未接线时保持演示提示。
final Future<void> Function()? onLogout;
/// 刻意保留的 demo 入口(ADR-022 未纳入本迭代):预约订单与健康卡包属
/// M5 服务域;地址定位需外部服务;设置与关于待有实际可设项。
/// 「我的收藏与草稿」的后端能力已就位(`/me/bookmarks`、`/me/posts`),
/// 但列表页本单未做(见 05 号报告遗留),故仍走演示提示。
static const menuItems = [
(Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'),
(Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'),
(Icons.badge_outlined, '宠物健康卡包', '电子接种证与体检报告'),
(Icons.location_on_outlined, '地址与定位管理', '管理家庭住址与常用医院'),
(Icons.settings_outlined, '设置与关于', '隐私设置与版本信息'),
];
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
ProfileController get _controller => widget.controller;
@override
void initState() {
super.initState();
// 主壳挂载即预取(IndexedStack 各 Tab 同时构建,沿 pets/home 先例);
// 重登后控制器已 reset 回 initial,会重新拉取。首页问候语也消费这一次。
//
// **预取推到帧末**`refresh()` 起手就同步 notify 一次(切 loading 态),
// 而同一控制器的另一个监听者(首页问候语)在本页 initState 时已构建完成
// ——在 initState 里同步通知它会命中 Flutter「build 期间 setState」断言。
// pets/home 各自只有自己一个监听者,故它们在 initState 里直取无妨。
if (_controller.phase == ProfileLoadPhase.initial) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _controller.phase == ProfileLoadPhase.initial) {
_controller.refresh();
}
});
}
}
void showDemoMessage(BuildContext context, String name) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('「$name」功能为演示入口')));
}
Future<void> _openEdit() async {
if (_controller.profile == null) return;
final saved = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => ProfileEditPage(
controller: _controller,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
),
),
);
if (saved == true && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('资料已更新')));
}
}
/// 刻意保留的 demo 家具:AppState 仍承载首页天气/本地服务的演示数据,
/// 这个入口是它唯一的复位口(不影响服务端真实资料)。
Future<void> reset(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('恢复演示数据'),
content: const Text('首页天气与本地服务的演示内容会恢复为初始状态(不影响账号资料与宠物档案)。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认恢复'),
),
],
),
);
if (confirmed == true) {
await widget.appState.resetDemoData();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('演示数据已恢复')));
}
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
switch (_controller.phase) {
case ProfileLoadPhase.initial:
case ProfileLoadPhase.loading:
return const Center(child: CircularProgressIndicator());
case ProfileLoadPhase.error:
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(
message: profileLoadErrorMessage(_controller.lastError),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _controller.refresh,
child: const Text('重试'),
),
],
),
),
);
case ProfileLoadPhase.ready:
return RefreshIndicator(
onRefresh: _controller.refresh,
child: _content(_controller.profile!),
);
}
},
);
}
Widget _content(UserProfile profile) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.ink,
borderRadius: BorderRadius.circular(30),
),
child: Column(
children: [
_ProfileAvatar(url: profile.avatarUrl, onTap: _openEdit),
const SizedBox(height: 12),
Text(
// 展示回退在客户端:`nickname ?? username`(服务端 /me 返回
// DB 原值不回退,见 UserProfile.displayName 的理由)。
profile.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 5),
Text(
'@${profile.username}',
style: const TextStyle(color: AppColors.accent, fontSize: 12),
),
const SizedBox(height: 12),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white38),
visualDensity: VisualDensity.compact,
),
onPressed: _openEdit,
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('编辑资料'),
),
const SizedBox(height: 16),
const Divider(color: Colors.white24),
const SizedBox(height: 10),
_statsRow(),
],
),
),
const SizedBox(height: 18),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
children: ProfilePage.menuItems.map((item) {
return ListTile(
leading: CircleAvatar(
backgroundColor: AppColors.surfaceTint,
child: Icon(item.$1, color: AppColors.primary),
),
title: Text(
item.$2,
style: const TextStyle(fontWeight: FontWeight.w700),
),
subtitle: Text(item.$3),
trailing: const Icon(Icons.chevron_right),
onTap: () => showDemoMessage(context, item.$2),
);
}).toList(),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () => reset(context),
icon: const Icon(Icons.restart_alt),
label: const Text('恢复演示数据'),
),
const SizedBox(height: 10),
TextButton(
onPressed: widget.onLogout ?? () => showDemoMessage(context, '退出登录'),
child: const Text('切换账号或退出登录'),
),
],
);
}
/// 统计块三态:loading 占位「—」+ 转圈 / error 提示 + 重试 / ready 实数。
///
/// 数字**不做 1.8k 式压缩**:获赞总数要能与帖子详情的 likeCount 逐一对上
/// (同一口径,含自赞),压缩会让「对不上」变成常态。
Widget _statsRow() {
switch (_controller.statsPhase) {
case ProfileStatsPhase.loading:
return const SizedBox(
height: 46,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
case ProfileStatsPhase.error:
return SizedBox(
height: 46,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'统计加载失败',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
TextButton(
onPressed: _controller.refreshStats,
child: const Text(
'重试',
style: TextStyle(color: AppColors.accent),
),
),
],
),
);
case ProfileStatsPhase.ready:
final follow = _controller.followStats;
final community = _controller.communityStats;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_ProfileStat(value: '${follow?.followerCount ?? 0}', label: '关注我'),
_ProfileStat(value: '${follow?.followingCount ?? 0}', label: '我关注'),
_ProfileStat(
value: '${community?.receivedLikeCount ?? 0}',
label: '获赞',
),
_ProfileStat(
value: '${community?.publishedPostCount ?? 0}',
label: '我的作品',
),
],
);
}
}
}
/// 资料页头像:有 URL 走 [RemoteImage](缓存 key 剥签名参数,URL 不落盘);
/// 无头像时本地占位(不显示破图)。
class _ProfileAvatar extends StatelessWidget {
const _ProfileAvatar({required this.url, required this.onTap});
final String? url;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
label: '编辑资料',
button: true,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(
width: 82,
height: 82,
child: ClipOval(
child: url == null
? const ColoredBox(
color: AppColors.surfaceTint,
child: Icon(
Icons.person_outline,
color: AppColors.muted,
size: 36,
),
)
: RemoteImage(
url: url!,
width: 82,
height: 82,
borderRadius: BorderRadius.circular(41),
),
),
),
),
);
}
}
class _ProfileStat extends StatelessWidget {
const _ProfileStat({required this.value, required this.label});
final String value;
final String label;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 3),
Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
);
}
}