Files
patbond-flutter/lib/features/main/main_shell_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

304 lines
11 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 'dart:async';
import 'package:flutter/material.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import 'package:patbond_flutter/features/community/post_compose_page.dart';
import 'package:patbond_flutter/features/create/create_page.dart';
import 'package:patbond_flutter/features/home/home_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
import 'package:patbond_flutter/features/pets/pets_controller.dart';
import 'package:patbond_flutter/features/pets/pets_page.dart';
import 'package:patbond_flutter/features/post/post_detail_page.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/features/profile/profile_page.dart';
import 'package:patbond_flutter/features/services/services_page.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
class MainShellPage extends StatefulWidget {
const MainShellPage({
required this.appState,
required this.petsController,
required this.communityController,
required this.profileController,
super.key,
this.currentUserId,
this.petAnalytics,
this.healthRecordAnalytics,
this.feedAnalytics,
this.interactionAnalytics,
this.postAnalytics,
this.mediaUploaderFactory,
this.avatarUploaderBuilder,
this.pageViewTracker,
this.onLogout,
});
final AppState appState;
/// 宠物档案状态(T2-11 拆出的独立 pets feature;档案 Tab 数据源)。
final PetsController petsController;
/// 社区状态(T3-12 数据层;首页 Feed segment 数据源,T3-14 接线)。
final CommunityController communityController;
/// 本人资料与社区数字(T3.5-08);资料 Tab 与首页问候语共用。
final ProfileController profileController;
/// 当前登录用户 id(详情页评论删除入口 / 关注钮自见性的 UI 判定)。
final String? currentUserId;
/// pet 域埋点强类型封装(建宠漏斗三事件)。
final PetAnalytics? petAnalytics;
/// health_record 域埋点强类型封装(T2-13 创建漏斗三事件 + viewed)。
final HealthRecordAnalytics? healthRecordAnalytics;
/// feed 域埋点(T3-14 聚合曝光 + 加载失败)。
final FeedAnalytics? feedAnalytics;
/// 互动域埋点(T3-16 评论成败对 + 关注对;详情页消费)。
final CommunityInteractionAnalytics? interactionAnalytics;
/// post 域埋点(T3-17 发布漏斗五事件 + 媒体三段;发布页消费)。
final PostAnalytics? postAnalytics;
/// 发布页 [MediaUploader] 构造口(桌面实测 / 测试替换选图与压缩层)。
final MediaUploaderFactory? mediaUploaderFactory;
/// 头像上传构造口(T3.5-08/09:资料编辑页与宠物详情页)。
final AvatarUploaderBuilder? avatarUploaderBuilder;
/// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。
final PageViewTracker? pageViewTracker;
/// 退出登录:调 logout 接口并清会话,认证状态机自动回登录页。
final Future<void> Function()? onLogout;
@override
State<MainShellPage> createState() => _MainShellPageState();
}
class _MainShellPageState extends State<MainShellPage> {
int currentIndex = 0;
bool showPersonalServices = false;
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
/// Tab 索引 → pageName 枚举(03 号评估 §3.2 映射表;档案 Tab 自 T2-12
/// 起为真实宠物列表,页名由 pet_archive 改报 pet_list06 §5.2 字典 v2)。
static const _tabPages = [
AnalyticsPageName.home,
AnalyticsPageName.create,
AnalyticsPageName.petList,
AnalyticsPageName.services,
AnalyticsPageName.profile,
];
@override
void initState() {
super.initState();
// 初始 Tab 曝光补一次(03 号评估 §3.2 手动补点 1)。
widget.pageViewTracker?.reportTab(_tabPages[currentIndex]);
}
void selectTab(int index) {
setState(() => currentIndex = index);
widget.pageViewTracker?.reportTab(_tabPages[index]);
}
void openServices(bool personal) {
setState(() {
showPersonalServices = personal;
currentIndex = 3;
});
widget.pageViewTracker?.reportTab(_tabPages[3]);
}
/// 帖子详情(T3-15:真实数据整页;Feed 卡片与详情共享
/// communityController,互动状态跨页一致)。
void openPost(String postId) {
Navigator.of(context).push(
MaterialPageRoute<void>(
settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName),
builder: (context) => PostDetailPage(
controller: widget.communityController,
postId: postId,
currentUserId: widget.currentUserId,
analytics: widget.interactionAnalytics,
),
),
);
}
/// 发布页(T3-17:真实发布链路)。发布成功后回首页 Feed 并整体刷新,
/// 新帖按 `(published_at DESC, id DESC)` 落在首位(跨客户端同一序)。
Future<void> openCompose(PostEntryPoint entryPoint) async {
final published = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
settings: RouteSettings(name: AnalyticsPageName.postForm.pageName),
builder: (context) => PostComposePage(
controller: widget.communityController,
entryPoint: entryPoint,
analytics: widget.postAnalytics,
uploaderFactory: widget.mediaUploaderFactory,
),
),
);
if (!mounted || published != true) return;
selectTab(0);
unawaited(widget.communityController.refresh());
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已发布,去首页看看吧 🐾')));
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.appState,
builder: (context, _) {
if (!widget.appState.isReady) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final pages = [
HomePage(
appState: widget.appState,
communityController: widget.communityController,
profileController: widget.profileController,
feedAnalytics: widget.feedAnalytics,
isActive: currentIndex == 0,
onOpenServices: openServices,
onOpenCompose: () => openCompose(PostEntryPoint.feed),
onOpenPost: openPost,
),
CreatePage(
appState: widget.appState,
// T3-17:发布半边已真实化(发布页 push),AI 生成模拟原样留 M4。
onOpenCompose: () => openCompose(PostEntryPoint.createTab),
),
PetsPage(
controller: widget.petsController,
analytics: widget.petAnalytics,
healthAnalytics: widget.healthRecordAnalytics,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
),
ServicesPage(
showPersonal: showPersonalServices,
locationWeather: widget.appState.locationWeather,
),
ProfilePage(
appState: widget.appState,
controller: widget.profileController,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
onLogout: widget.onLogout,
),
];
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white.withAlpha(245),
surfaceTintColor: Colors.transparent,
scrolledUnderElevation: 1,
centerTitle: true,
title: Text(
titles[currentIndex],
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
),
leadingWidth: 118,
leading: InkWell(
onTap: () => selectTab(2),
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.only(left: 14),
child: Row(
children: [
RemoteImage(
url: widget.appState.pet.avatarUrl,
width: 36,
height: 36,
borderRadius: BorderRadius.circular(18),
),
const SizedBox(width: 7),
const Expanded(
child: Text(
'Patbond',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.primary,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
),
actions: [
IconButton.filledTonal(
tooltip: '通知',
onPressed: () {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('暂无新通知 🐾')));
},
icon: const Icon(Icons.notifications_outlined, size: 21),
),
const SizedBox(width: 10),
],
),
body: SafeArea(
top: false,
child: IndexedStack(index: currentIndex, children: pages),
),
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: selectTab,
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_rounded),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.auto_awesome_outlined),
selectedIcon: Icon(Icons.auto_awesome),
label: '创作',
),
NavigationDestination(
icon: Icon(Icons.pets_outlined),
selectedIcon: Icon(Icons.pets),
label: '档案',
),
NavigationDestination(
icon: Icon(Icons.storefront_outlined),
selectedIcon: Icon(Icons.storefront),
label: '服务',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
],
),
);
},
);
}
}