a4a97c03c2
资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,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>
386 lines
14 KiB
Dart
386 lines
14 KiB
Dart
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';
|
||
|
||
/// 「我的资料」Tab(T3.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),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|