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 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 createState() => _ProfilePageState(); } class _ProfilePageState extends State { 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 _openEdit() async { if (_controller.profile == null) return; final saved = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ProfileEditPage( controller: _controller, avatarUploaderBuilder: widget.avatarUploaderBuilder, ), ), ); if (saved == true && mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('资料已更新'))); } } /// 刻意保留的 demo 家具:AppState 仍承载首页天气/本地服务的演示数据, /// 这个入口是它唯一的复位口(不影响服务端真实资料)。 Future reset(BuildContext context) async { final confirmed = await showDialog( 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), ), ], ); } }