Files
patbond-flutter/lib/app/app.dart
T
lixi f873acf9a3
CI / flutter-gates (push) Failing after 20s
新增:帖子详情页真实数据整页替换——四态/真九宫格大图/评论区/关注双态,Feed 导航与互动全接线,demo 详情退役(T3-15/16)
- post_detail_page 整页重写:内存副本先渲染 + getPost 拉新、四态
 (loading/error+重试/ready/40403 提示后返回 Feed 并刷新);媒体全量
  ——单图原比例(高度钳制 [0.75w,1.33w])、多图真九宫格(D11 轮播
  弃用,本单裁定)+ InteractiveViewer 全屏大图(03 号拍板 E)
- 评论区:游标列表触底补页 + 底部输入条创建(幂等键在数据层)+
  仅本人评论渲染删除入口(40301/40404 服务端兜底);成败埋点对
 (durationMs 取输入会话起点、attemptSeq 会话内递增);CommentTile
  升共享组件(@ 回复前缀呈现、降级作者「宠友」占位)
- 关注双态钮(tonal +关注 / Outlined 已关注 + 取关确认)乐观翻转、
  回滚零动画 + SnackBar,user_followed/unfollowed(post_detail) 挂接;
  本人帖不渲染
- Feed 接通:整卡与评论钮导航详情(T3-14 占位提示移除)、点赞/收藏
  接共享 ToggleSync(详情与卡片同一实例跨页一致)、toggleError
  一次性 SnackBar 消费
- 主壳/装配:openPost 按 postId push;create demo 流不再导航已退役的
  demo 详情(发布页真实化留 T3-17);AppState 在 post_detail/Feed 面
  消费清零
- compose 冒烟(env 门控默认跳过):真链路发帖/点赞幂等/收藏/评论/
  删评一轮 + 断网点赞回滚(生产 ApiClient 异常链)

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

275 lines
9.9 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: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/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,
});
/// 测试注入口;生产默认走安全存储 + 真实 API。
final SessionManager? sessionManager;
final AuthRepository? authRepository;
final PetsRepository? petsRepository;
final CommunityRepository? communityRepository;
@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 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);
// 认证状态切换补点(根路由 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,
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(),
);
},
),
);
}
}