Files
patbond-flutter/lib/app/app.dart
T
lixi 9892b65a19
CI / flutter-gates (push) Successful in 3m9s
新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目
  (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay,
  删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready)
- 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿
  已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、
  网络失败同键重放不重复建帖;40902 自动取新 version 重提一次
- 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新
  一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点)
- 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典
  v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq;
  pageName 枚举补 post_form
- PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 +
  进度覆盖层 + 失败整格重试;页级上传汇总条
- create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts /
  publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态
  CTA 改为 push 真实发布页
- 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态
  九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控)
- flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过
  (选图上传 → 发布 → Feed 置顶 → 第二客户端可见)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 14:42:13 +08:00

286 lines
10 KiB
Dart
Raw Permalink 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:package_info_plus/package_info_plus.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/analytics/analytics_route_observer.dart';
import 'package:patbond_flutter/analytics/analytics_service.dart';
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/analytics/session_tracker.dart';
import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/token_refresher.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/auth/auth_repository.dart';
import 'package:patbond_flutter/features/auth/login_page.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/auth/splash_page.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/community_repository.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/main/main_shell_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_repository.dart';
import 'package:patbond_flutter/state/app_state.dart';
class App extends StatefulWidget {
const App({
super.key,
this.sessionManager,
this.authRepository,
this.petsRepository,
this.communityRepository,
this.mediaUploaderFactory,
});
/// 测试注入口;生产默认走安全存储 + 真实 API。
final SessionManager? sessionManager;
final AuthRepository? authRepository;
final PetsRepository? petsRepository;
final CommunityRepository? communityRepository;
/// 发布页媒体上传器构造口(桌面实测替换选图/压缩层;生产为 null)。
final MediaUploaderFactory? mediaUploaderFactory;
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
late final AppState appState;
late final SessionManager sessionManager;
late final AuthRepository authRepository;
late final PetsController petsController;
late final CommunityController communityController;
late final PetAnalytics petAnalytics;
late final HealthRecordAnalytics healthRecordAnalytics;
late final FeedAnalytics feedAnalytics;
late final CommunityInteractionAnalytics interactionAnalytics;
late final PostAnalytics postAnalytics;
late final SessionTracker _sessionTracker;
late final AnalyticsService _analytics;
late final PageViewTracker _pageViewTracker;
late final AnalyticsRouteObserver _routeObserver;
TokenRefresher? _sharedRefresher;
@override
void initState() {
super.initState();
appState = AppState()..load();
sessionManager =
widget.sessionManager ??
SessionManager(store: const SecureTokenStore());
_sessionTracker = SessionTracker(
onLeaveForeground: () {
// 退后台:停定时冲刷并立即冲刷一次(既有触发点保留)。
_analytics.stopPeriodicFlush();
_analytics.flushNow();
},
onEnterForeground: () => _analytics.startPeriodicFlush(),
);
WidgetsBinding.instance.addObserver(_sessionTracker);
_analytics = AnalyticsService(
apiBaseUrl: patbondUserApiBaseUrl,
getAccessToken: () => sessionManager.accessToken,
getSessionId: () => _sessionTracker.sessionId,
);
_initAppVersion();
// 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。
_analytics.restore();
// 前台期间 30 秒定时冲刷(13 号 §3.4 第 4 触发点)。
_analytics.startPeriodicFlush();
_pageViewTracker = PageViewTracker(_analytics.trackEvent);
_routeObserver = AnalyticsRouteObserver(
tracker: _pageViewTracker,
resolveRootPage: _resolveRootPage,
);
authRepository = widget.authRepository ?? _buildRepository();
petsController = PetsController(
repository: widget.petsRepository ?? _buildPetsRepository(),
);
// T3-14Feed segment 接线主壳(数据层 T3-12 就位);T3-16 起
// 点赞/收藏成功埋点经 interactionAnalytics 在 controller 内上报。
interactionAnalytics = CommunityInteractionAnalytics(_analytics.trackEvent);
communityController = CommunityController(
repository: widget.communityRepository ?? _buildCommunityRepository(),
interactionAnalytics: interactionAnalytics,
);
petAnalytics = PetAnalytics(_analytics.trackEvent);
healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent);
feedAnalytics = FeedAnalytics(_analytics.trackEvent);
// T3-17:发布漏斗五事件 + 媒体上传三段(发布页与 MediaUploader 消费)。
postAnalytics = PostAnalytics(_analytics.trackEvent);
// 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件)
sessionManager.addListener(_reportAuthStateChange);
}
/// 异步读取 appVersion 并注入 analytics(启动后台任务,不阻塞渲染)。
Future<void> _initAppVersion() async {
try {
final info = await PackageInfo.fromPlatform();
_analytics.setAppVersion('${info.version}+${info.buildNumber}');
} catch (error) {
// 读取失败回退 'unknown'AnalyticsService 构造默认值已兜底)。
}
}
/// 三服务分端口直连(ADR-002 无网关),token 刷新单飞经共享
/// [TokenRefresher]22 号报告 §7 交接约定)。
TokenRefresher _ensureRefresher() {
return _sharedRefresher ??= TokenRefresher(
dio: buildPatbondDio(session: sessionManager),
session: sessionManager,
);
}
AuthRepository _buildRepository() {
final dio = buildPatbondDio(session: sessionManager);
final refresher = _ensureRefresher();
final api = ApiClient(
dio: dio,
session: sessionManager,
refresher: refresher,
);
return ApiAuthRepository(
api: api,
session: sessionManager,
refresher: refresher,
analytics: _analytics,
);
}
PetsRepository _buildPetsRepository() {
final dio = buildPatbondDio(
session: sessionManager,
baseUrl: patbondPetApiBaseUrl,
);
final api = ApiClient(
dio: dio,
session: sessionManager,
refresher: _ensureRefresher(),
);
return ApiPetsRepository(api: api);
}
/// community 服务分端口直连(:8084);media 两步上传端点由 user 服务
/// :8082 MediaController)提供,单独建 mediaApi 直连。token 刷新
/// 单飞经共享 [TokenRefresher]。
CommunityRepository _buildCommunityRepository() {
final dio = buildPatbondDio(
session: sessionManager,
baseUrl: patbondCommunityApiBaseUrl,
);
final api = ApiClient(
dio: dio,
session: sessionManager,
refresher: _ensureRefresher(),
);
final mediaDio = buildPatbondDio(
session: sessionManager,
baseUrl: patbondUserApiBaseUrl,
);
final mediaApi = ApiClient(
dio: mediaDio,
session: sessionManager,
refresher: _ensureRefresher(),
);
return ApiCommunityRepository(api: api, mediaApi: mediaApi);
}
AnalyticsPageName? _resolveRootPage() {
// 回栈到无名根路由时解析当前认证状态页/主壳 Tab
return switch (sessionManager.status) {
AuthStatus.unknown => null,
AuthStatus.unauthenticated => AnalyticsPageName.login,
AuthStatus.authenticated => _pageViewTracker.currentTab,
};
}
void _reportAuthStateChange() {
// 登出即清宠物档案与社区 Feed 内存副本(跨账号不泄漏;重登后重新拉取)。
if (sessionManager.status == AuthStatus.unauthenticated) {
petsController.reset();
communityController.reset();
}
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
final page = switch (sessionManager.status) {
AuthStatus.unauthenticated => AnalyticsPageName.login,
AuthStatus.authenticated => _pageViewTracker.currentTab,
AuthStatus.unknown => null,
};
if (page != null) _pageViewTracker.report(page);
}
@override
void dispose() {
_analytics.stopPeriodicFlush();
sessionManager.removeListener(_reportAuthStateChange);
WidgetsBinding.instance.removeObserver(_sessionTracker);
appState.dispose();
petsController.dispose();
communityController.dispose();
if (widget.sessionManager == null) sessionManager.dispose();
super.dispose();
}
Widget _homeForStatus() {
switch (sessionManager.status) {
case AuthStatus.unknown:
return SplashPage(
key: const ValueKey('splash'),
authRepository: authRepository,
sessionManager: sessionManager,
);
case AuthStatus.unauthenticated:
return LoginPage(
key: const ValueKey('login'),
authRepository: authRepository,
);
case AuthStatus.authenticated:
return MainShellPage(
key: const ValueKey('shell'),
appState: appState,
petsController: petsController,
communityController: communityController,
currentUserId: sessionManager.userId,
petAnalytics: petAnalytics,
healthRecordAnalytics: healthRecordAnalytics,
feedAnalytics: feedAnalytics,
interactionAnalytics: interactionAnalytics,
postAnalytics: postAnalytics,
mediaUploaderFactory: widget.mediaUploaderFactory,
pageViewTracker: _pageViewTracker,
onLogout: authRepository.logout,
);
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Patbond',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
navigatorObservers: [_routeObserver],
home: ListenableBuilder(
listenable: sessionManager,
builder: (context, _) {
// 认证状态切换统一 300ms 淡入淡出(Splash ↔ 登录 ↔ 主壳,
// BrandMark 同构保证品牌区过渡对位,组装稿 §7)。
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _homeForStatus(),
);
},
),
);
}
}