Compare commits
12 Commits
c91f18a845
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e87413360 | |||
| 9892b65a19 | |||
| f873acf9a3 | |||
| 92524da8e2 | |||
| 8aac8c52cc | |||
| 1441f0148f | |||
| 19bd8c1810 | |||
| 66f983d680 | |||
| 4d40c38f06 | |||
| 720865bcb9 | |||
| ba503327f5 | |||
| e186ba3da9 |
@@ -26,6 +26,10 @@ jobs:
|
|||||||
git remote add origin "$AUTH_URL/${{ github.repository }}.git"
|
git remote add origin "$AUTH_URL/${{ github.repository }}.git"
|
||||||
git fetch -q --depth 1 origin "+${{ github.ref }}:refs/ci-head"
|
git fetch -q --depth 1 origin "+${{ github.ref }}:refs/ci-head"
|
||||||
git checkout -q refs/ci-head
|
git checkout -q refs/ci-head
|
||||||
|
# 凭证防泄漏兜底(ADR-021):与本地 pre-commit 同一脚本、同一规则表,
|
||||||
|
# 扫全部已跟踪文件(覆盖本次 push 变更的超集),纯 shell 零外部依赖。
|
||||||
|
- name: Secret scan
|
||||||
|
run: sh scripts/check-secrets.sh --all
|
||||||
- name: Install Flutter (flutter-io.cn mirror, toolcache reuse)
|
- name: Install Flutter (flutter-io.cn mirror, toolcache reuse)
|
||||||
run: |
|
run: |
|
||||||
FLUTTER_VERSION=3.44.6
|
FLUTTER_VERSION=3.44.6
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:patbond_flutter/app/app.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_card.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
|
|
||||||
|
/// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。
|
||||||
|
class _InMemoryTokenStore implements TokenStore {
|
||||||
|
final Map<String, String> _values = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read(String key) async => _values[key];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, String value) async => _values[key] = value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String key) async => _values.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// T3-14 compose 真链路桌面实测(默认跳过,不计入常规测试套件):
|
||||||
|
///
|
||||||
|
/// ```bash
|
||||||
|
/// # 先起后端六容器(patbond-api 仓库根)并用 scratch 种子脚本发帖,再:
|
||||||
|
/// PATBOND_FEED_LIVE=1 flutter test integration_test/feed_live_test.dart -d linux
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 驱动**真实 App**(Linux 桌面渲染管线 + 真实 HTTP + MinIO 预签名图片):
|
||||||
|
/// 注册账号 → UI 登录 → Feed 首屏真数据 → 滚动触底游标翻页到底 →
|
||||||
|
/// 下拉刷新。会话存储注入内存实现(桌面环境无 keyring,不动生产配置)。
|
||||||
|
void main() {
|
||||||
|
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||||
|
|
||||||
|
final enabled = Platform.environment['PATBOND_FEED_LIVE'] == '1';
|
||||||
|
const authBase = 'http://127.0.0.1:8081';
|
||||||
|
|
||||||
|
Future<void> pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
Duration timeout = const Duration(seconds: 20),
|
||||||
|
}) async {
|
||||||
|
final deadline = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 250));
|
||||||
|
if (finder.evaluate().isNotEmpty) return;
|
||||||
|
}
|
||||||
|
fail('等待超时:$finder');
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('Feed 桌面真链路:登录 → 首屏 → 游标翻页到底 → 下拉刷新', (tester) async {
|
||||||
|
// ---- 注册一次性账号(随机凭据,不落持久化)----
|
||||||
|
final seed = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final username = 'feedlive$seed';
|
||||||
|
final password = 'Live1234!$seed';
|
||||||
|
final client = HttpClient();
|
||||||
|
final request = await client.postUrl(
|
||||||
|
Uri.parse('$authBase/api/v1/auth/register'),
|
||||||
|
);
|
||||||
|
request.headers.contentType = ContentType.json;
|
||||||
|
request.add(
|
||||||
|
utf8.encode(
|
||||||
|
jsonEncode({
|
||||||
|
'username': username,
|
||||||
|
'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}',
|
||||||
|
'password': password,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final response = await request.close();
|
||||||
|
expect(response.statusCode, 200, reason: '注册测试账号失败');
|
||||||
|
client.close();
|
||||||
|
|
||||||
|
// ---- 启动真实 App(仅注入内存会话存储,其余全为生产实现)----
|
||||||
|
await tester.pumpWidget(
|
||||||
|
App(sessionManager: SessionManager(store: _InMemoryTokenStore())),
|
||||||
|
);
|
||||||
|
await pumpUntil(tester, find.text('登录'));
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).at(0), username);
|
||||||
|
await tester.enterText(find.byType(TextField).at(1), password);
|
||||||
|
await tester.tap(find.text('登录'));
|
||||||
|
|
||||||
|
// ---- Feed 首屏:真实 getFeed + 卡片渲染 ----
|
||||||
|
await pumpUntil(tester, find.byType(PostCard));
|
||||||
|
expect(find.textContaining('compose 实测'), findsWidgets);
|
||||||
|
|
||||||
|
// ---- 触底翻页到「没有更多了」(26 帖 / 服务端页长 20 → 两页取齐)----
|
||||||
|
final list = find.byType(ListView).first;
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
await tester.fling(list, const Offset(0, -700), 1500);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
if (find.text('没有更多了').evaluate().isNotEmpty) break;
|
||||||
|
}
|
||||||
|
await pumpUntil(tester, find.text('没有更多了'));
|
||||||
|
// 最早一帖(#1)在第二页尾部——游标翻页取齐的直接证据。
|
||||||
|
expect(find.textContaining('#1:'), findsOneWidget);
|
||||||
|
|
||||||
|
// ---- 回顶下拉刷新:整体替换后首屏仍在 ----
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
await tester.fling(list, const Offset(0, 700), 1500);
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
await tester.fling(list, const Offset(0, 400), 1000);
|
||||||
|
await pumpUntil(tester, find.byType(PostCard));
|
||||||
|
expect(find.textContaining('compose 实测'), findsWidgets);
|
||||||
|
}, skip: !enabled);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:patbond_flutter/app/app.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_card.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_compression.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_picking.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
|
||||||
|
/// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。
|
||||||
|
class _InMemoryTokenStore implements TokenStore {
|
||||||
|
final Map<String, String> _values = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read(String key) async => _values[key];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, String value) async => _values[key] = value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String key) async => _values.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1x1 真 PNG(70B):桌面实测的「选中的照片」。
|
||||||
|
final Uint8List _pngBytes = base64Decode(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAF'
|
||||||
|
'AAH/q842iQAAAABJRU5ErkJggg==',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 桌面选图替身:Linux 桌面无 image_picker 平台实现,实测注入字节。
|
||||||
|
class _DesktopPicker implements MediaImagePicker {
|
||||||
|
@override
|
||||||
|
Future<List<PickedMediaImage>> pickImages({required int limit}) async => [
|
||||||
|
PickedMediaImage(bytes: _pngBytes, name: 'live.png'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 桌面压缩替身:Linux 桌面无 flutter_image_compress 原生实现,原样透传
|
||||||
|
/// (mime 落 image/png,在服务端白名单内)。其余环节全为生产实现。
|
||||||
|
class _PassthroughCompressor implements MediaImageCompressor {
|
||||||
|
@override
|
||||||
|
Future<CompressedMediaImage> compress(
|
||||||
|
PickedMediaImage source, {
|
||||||
|
required int quality,
|
||||||
|
}) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// T3-17 发布链路桌面真链路实测(默认跳过,不计入常规测试套件):
|
||||||
|
///
|
||||||
|
/// ```bash
|
||||||
|
/// # 先起后端六容器(patbond-api 仓库根:docker compose up -d --build),再:
|
||||||
|
/// PATBOND_PUBLISH_LIVE=1 flutter test integration_test/publish_live_test.dart -d linux
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 驱动**真实 App**(Linux 桌面渲染 + 真实 HTTP + MinIO 预签名直传):
|
||||||
|
/// 注册两个一次性账号 → A 登录 → 创作 Tab「发布动态」→ 输入正文 → 选图
|
||||||
|
/// 上传(真 createUpload / 真预签名 PUT / 真 confirm)→ 发布(建草稿 →
|
||||||
|
/// PATCH 迁移)→ 回首页 Feed 新帖置顶可见 → **另起一个全新 App 实例
|
||||||
|
/// (新会话/新控制器/新 HTTP 客户端)以 B 账号登录,B 的 Feed 同样看到
|
||||||
|
/// 该帖**(M3 验收「发布后可在另一客户端看到」取证)。
|
||||||
|
///
|
||||||
|
/// 桌面替身仅两处:选图与压缩(Linux 无这两个插件的平台实现)。
|
||||||
|
/// 注意:桌面 `platform` 值为 `linux`,不在契约枚举内 → 埋点批次被服务端
|
||||||
|
/// 400 整批拒绝(既有预期行为,见 analytics_service.dart 注释);埋点落库
|
||||||
|
/// 取证走 curl(26 号报告 §5c)。
|
||||||
|
void main() {
|
||||||
|
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||||
|
|
||||||
|
final enabled = Platform.environment['PATBOND_PUBLISH_LIVE'] == '1';
|
||||||
|
const authBase = 'http://127.0.0.1:8081';
|
||||||
|
|
||||||
|
Future<void> pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
Duration timeout = const Duration(seconds: 30),
|
||||||
|
}) async {
|
||||||
|
final deadline = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 250));
|
||||||
|
if (finder.evaluate().isNotEmpty) return;
|
||||||
|
}
|
||||||
|
fail('等待超时:$finder');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> register(String username, String password, int seed) async {
|
||||||
|
final client = HttpClient();
|
||||||
|
final request = await client.postUrl(
|
||||||
|
Uri.parse('$authBase/api/v1/auth/register'),
|
||||||
|
);
|
||||||
|
request.headers.contentType = ContentType.json;
|
||||||
|
request.add(
|
||||||
|
utf8.encode(
|
||||||
|
jsonEncode({
|
||||||
|
'username': username,
|
||||||
|
'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}',
|
||||||
|
'password': password,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final response = await request.close();
|
||||||
|
expect(response.statusCode, 200, reason: '注册测试账号失败:$username');
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> login(
|
||||||
|
WidgetTester tester,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
) async {
|
||||||
|
await pumpUntil(tester, find.text('登录'));
|
||||||
|
await tester.enterText(find.byType(TextField).at(0), username);
|
||||||
|
await tester.enterText(find.byType(TextField).at(1), password);
|
||||||
|
await tester.tap(find.text('登录'));
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('发布桌面真链路:选图上传 → 发布 → Feed 置顶 → 第二账号可见', (tester) async {
|
||||||
|
final seed = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final authorName = 'publive$seed';
|
||||||
|
final readerName = 'pubread$seed';
|
||||||
|
const password = 'Live1234!publish';
|
||||||
|
final marker = '真链路发布 $seed';
|
||||||
|
|
||||||
|
await register(authorName, password, seed);
|
||||||
|
await register(readerName, password, seed + 1);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
App(
|
||||||
|
sessionManager: SessionManager(store: _InMemoryTokenStore()),
|
||||||
|
mediaUploaderFactory: (repository, analytics) => MediaUploader(
|
||||||
|
repository: repository,
|
||||||
|
picker: _DesktopPicker(),
|
||||||
|
compressor: _PassthroughCompressor(),
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- A 登录并发布(正文 + 一张真上传的图)----
|
||||||
|
await login(tester, authorName, password);
|
||||||
|
await pumpUntil(tester, find.text('创作'));
|
||||||
|
await tester.tap(find.text('创作'));
|
||||||
|
await pumpUntil(tester, find.text('发布动态'));
|
||||||
|
await tester.tap(find.text('发布动态'));
|
||||||
|
await pumpUntil(tester, find.widgetWithText(TextButton, '存草稿'));
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, marker);
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
|
||||||
|
// 上传全程(createUpload → 预签名 PUT → confirm)走真实链路。
|
||||||
|
await pumpUntil(tester, find.byType(Image));
|
||||||
|
final publish = find.widgetWithText(FilledButton, '发布');
|
||||||
|
for (var i = 0; i < 120; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 250));
|
||||||
|
if (tester.widget<FilledButton>(publish).onPressed != null) break;
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
tester.widget<FilledButton>(publish).onPressed,
|
||||||
|
isNotNull,
|
||||||
|
reason: '图片未在 30s 内 ready,发布钮仍禁用',
|
||||||
|
);
|
||||||
|
await tester.tap(publish);
|
||||||
|
|
||||||
|
// ---- 回首页 Feed:新帖置顶可见 ----
|
||||||
|
await pumpUntil(tester, find.byType(PostCard));
|
||||||
|
await pumpUntil(tester, find.textContaining(marker));
|
||||||
|
final cards = tester.widgetList<PostCard>(find.byType(PostCard)).toList();
|
||||||
|
expect(cards.first.card.contentPreview, contains(marker));
|
||||||
|
expect(cards.first.card.mediaCount, 1);
|
||||||
|
|
||||||
|
// ---- 第二客户端(全新 App 实例 + 全新会话):B 登录后同样看到该帖 ----
|
||||||
|
// 换 key 强制重建整棵树:新的 SessionManager / Controller / HTTP 客户端,
|
||||||
|
// 等价于另一台客户端首次登录(不是同一实例内的账号切换)。
|
||||||
|
await tester.pumpWidget(
|
||||||
|
App(
|
||||||
|
key: const ValueKey('client-b'),
|
||||||
|
sessionManager: SessionManager(store: _InMemoryTokenStore()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await login(tester, readerName, password);
|
||||||
|
await pumpUntil(tester, find.byType(PostCard));
|
||||||
|
await pumpUntil(tester, find.textContaining(marker));
|
||||||
|
final readerCards = tester
|
||||||
|
.widgetList<PostCard>(find.byType(PostCard))
|
||||||
|
.toList();
|
||||||
|
expect(readerCards.first.card.contentPreview, contains(marker));
|
||||||
|
}, skip: !enabled);
|
||||||
|
}
|
||||||
@@ -19,7 +19,10 @@ enum AnalyticsPageName {
|
|||||||
create('create'),
|
create('create'),
|
||||||
petArchive('pet_archive'),
|
petArchive('pet_archive'),
|
||||||
services('services'),
|
services('services'),
|
||||||
postDetail('post_detail');
|
postDetail('post_detail'),
|
||||||
|
// —— 字典 v3 页面族(22 号报告 §2;本枚举登记 M3 已落地页)——
|
||||||
|
/// 发布页(P3,T3-17 push 全屏页)。
|
||||||
|
postForm('post_form');
|
||||||
|
|
||||||
const AnalyticsPageName(this.pageName);
|
const AnalyticsPageName(this.pageName);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
|
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
/// Analytics client for report 13: track events to backend POST
|
/// Analytics client for report 13: track events to backend POST
|
||||||
/// /api/v1/events. Events land in a segmented persistent queue
|
/// /api/v1/events. Events land in a segmented persistent queue
|
||||||
/// ([AnalyticsEventStore], shared_preferences, cap 500 oldest-dropped),
|
/// ([AnalyticsEventStore], shared_preferences, cap 500 oldest-dropped),
|
||||||
/// flushed every 20 events and on leaving foreground; cold start [restore]
|
/// flushed every 20 events, every [flushInterval] while foregrounded
|
||||||
/// re-uploads offline backlog. Privacy red-line enforced locally.
|
/// ([startPeriodicFlush]) and on leaving foreground; cold start [restore]
|
||||||
|
/// re-uploads offline backlog. Upload failures back off exponentially
|
||||||
|
/// (periodic flush only; explicit triggers unaffected). Privacy red-line
|
||||||
|
/// enforced locally.
|
||||||
class AnalyticsService {
|
class AnalyticsService {
|
||||||
AnalyticsService({
|
AnalyticsService({
|
||||||
required this.apiBaseUrl,
|
required this.apiBaseUrl,
|
||||||
@@ -16,10 +21,14 @@ class AnalyticsService {
|
|||||||
required this.getSessionId,
|
required this.getSessionId,
|
||||||
String? anonymousId,
|
String? anonymousId,
|
||||||
AnalyticsEventStore? store,
|
AnalyticsEventStore? store,
|
||||||
|
this.flushInterval = const Duration(seconds: 30),
|
||||||
|
DateTime Function()? now,
|
||||||
}) : _anonymousId = anonymousId ?? const Uuid().v4(),
|
}) : _anonymousId = anonymousId ?? const Uuid().v4(),
|
||||||
|
_anonymousIdInjected = anonymousId != null,
|
||||||
_appVersion = 'unknown',
|
_appVersion = 'unknown',
|
||||||
_osVersion = _defaultOsVersion(),
|
_osVersion = _defaultOsVersion(),
|
||||||
_store = store ?? AnalyticsEventStore();
|
_store = store ?? AnalyticsEventStore(),
|
||||||
|
_now = now ?? DateTime.now;
|
||||||
|
|
||||||
/// 异步设置 appVersion(app.dart 启动时从 package_info_plus 读取后注入)。
|
/// 异步设置 appVersion(app.dart 启动时从 package_info_plus 读取后注入)。
|
||||||
void setAppVersion(String version) {
|
void setAppVersion(String version) {
|
||||||
@@ -32,23 +41,49 @@ class AnalyticsService {
|
|||||||
// 契约单批上限(13 号规范 §1.1:单批 1–50 条),冲刷时按段拼批循环上传。
|
// 契约单批上限(13 号规范 §1.1:单批 1–50 条),冲刷时按段拼批循环上传。
|
||||||
static const _maxBatchEvents = 50;
|
static const _maxBatchEvents = 50;
|
||||||
|
|
||||||
|
// 失败退避:首次 30 秒,×2 递增封顶 5 分钟(13 号 §3.4 / iteration-3
|
||||||
|
// 06 号 §2.3);只挡定时冲刷,显式触发(flushNow / 满 20 / 冷启动)不受限。
|
||||||
|
static const _backoffInitial = Duration(seconds: 30);
|
||||||
|
static const _backoffCap = Duration(minutes: 5);
|
||||||
|
|
||||||
|
/// anonymousId 持久化 key(13 号规范 §3.3):首次生成后跨冷启动稳定,
|
||||||
|
/// 登录前事件才能跨启动归并(A/B 前置 #4 硬依赖)。
|
||||||
|
static const anonymousIdKey = 'pb.analytics.anonymousId';
|
||||||
|
|
||||||
final String apiBaseUrl;
|
final String apiBaseUrl;
|
||||||
final String? Function()? getAccessToken;
|
final String? Function()? getAccessToken;
|
||||||
|
|
||||||
/// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。
|
/// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。
|
||||||
final String Function() getSessionId;
|
final String Function() getSessionId;
|
||||||
|
|
||||||
final String _anonymousId;
|
/// 前台定时冲刷周期(13 号 §3.4 第 4 触发点),构造参数化便于测试注入。
|
||||||
|
final Duration flushInterval;
|
||||||
|
|
||||||
|
String _anonymousId;
|
||||||
|
|
||||||
|
/// 构造显式注入 anonymousId 的测试通道不参与持久化采用/落盘。
|
||||||
|
final bool _anonymousIdInjected;
|
||||||
String _appVersion;
|
String _appVersion;
|
||||||
final String _osVersion;
|
final String _osVersion;
|
||||||
String? _userId;
|
String? _userId;
|
||||||
bool _flushing = false;
|
bool _flushing = false;
|
||||||
final AnalyticsEventStore _store;
|
final AnalyticsEventStore _store;
|
||||||
|
|
||||||
|
/// 时钟注入口(照 SessionTracker 先例),退避判定测试免真实等待。
|
||||||
|
final DateTime Function() _now;
|
||||||
|
|
||||||
|
Timer? _flushTimer;
|
||||||
|
Duration? _backoffDelay;
|
||||||
|
DateTime? _retryNotBefore;
|
||||||
|
|
||||||
/// 待上报事件(测试断言用,生产代码不得直接操作)。
|
/// 待上报事件(测试断言用,生产代码不得直接操作)。
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
List<Map<String, dynamic>> get pendingEvents => _store.events;
|
List<Map<String, dynamic>> get pendingEvents => _store.events;
|
||||||
|
|
||||||
|
/// 当前匿名标识(测试断言用)。
|
||||||
|
@visibleForTesting
|
||||||
|
String get anonymousId => _anonymousId;
|
||||||
|
|
||||||
/// 粗粒度 osVersion(13 号规范 §4.0:主版本级,如 android-14)。
|
/// 粗粒度 osVersion(13 号规范 §4.0:主版本级,如 android-14)。
|
||||||
/// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。
|
/// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。
|
||||||
static String _defaultOsVersion() {
|
static String _defaultOsVersion() {
|
||||||
@@ -121,10 +156,12 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 冷启动恢复持久化队列(离线积压约两周容量),有积压即冲刷一次
|
/// 冷启动恢复:先采用持久化 anonymousId,再恢复持久化队列(离线积压
|
||||||
/// (13 号规范 §3.4 冷启动触发)。app 启动时调用,不阻塞渲染。
|
/// 约两周容量),有积压即冲刷一次(13 号规范 §3.4 冷启动触发)。
|
||||||
|
/// app 启动时调用,不阻塞渲染。
|
||||||
Future<void> restore() async {
|
Future<void> restore() async {
|
||||||
try {
|
try {
|
||||||
|
await _restoreAnonymousId();
|
||||||
await _store.restore();
|
await _store.restore();
|
||||||
if (_store.length > 0) {
|
if (_store.length > 0) {
|
||||||
await _flush();
|
await _flush();
|
||||||
@@ -134,6 +171,43 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 采用/落盘持久化 anonymousId:已有存储值则采用(跨启动稳定),
|
||||||
|
/// 无则把本次生成的落盘。持久化不可用时降级为进程内临时 id,
|
||||||
|
/// 绝不抛出(埋点旁路原则)。
|
||||||
|
Future<void> _restoreAnonymousId() async {
|
||||||
|
if (_anonymousIdInjected) return;
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final stored = prefs.getString(anonymousIdKey);
|
||||||
|
if (stored != null && stored.isNotEmpty) {
|
||||||
|
_anonymousId = stored;
|
||||||
|
} else {
|
||||||
|
await prefs.setString(anonymousIdKey, _anonymousId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Analytics: anonymousId falls back to ephemeral: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动前台定时冲刷(13 号 §3.4 第 4 触发点):长前台会话(刷 Feed
|
||||||
|
/// 半小时不切页)不再积压不上传。app 启动与回前台时调用,幂等。
|
||||||
|
void startPeriodicFlush() {
|
||||||
|
_flushTimer ??= Timer.periodic(flushInterval, (_) => _onFlushTimerTick());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止定时冲刷(退后台与 App dispose 时调用),退避状态保留。
|
||||||
|
void stopPeriodicFlush() {
|
||||||
|
_flushTimer?.cancel();
|
||||||
|
_flushTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFlushTimerTick() {
|
||||||
|
// 退避窗口内跳过定时冲刷;显式触发(flushNow / 满 20 / 冷启动)不受限。
|
||||||
|
final notBefore = _retryNotBefore;
|
||||||
|
if (notBefore != null && _now().isBefore(notBefore)) return;
|
||||||
|
_flush();
|
||||||
|
}
|
||||||
|
|
||||||
/// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满
|
/// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满
|
||||||
/// [_flushThreshold] 条导致事件永不上传)。
|
/// [_flushThreshold] 条导致事件永不上传)。
|
||||||
Future<void> flushNow() => _flush();
|
Future<void> flushNow() => _flush();
|
||||||
@@ -147,22 +221,40 @@ class AnalyticsService {
|
|||||||
// 取段拼批(入选段即封段,冲刷中的新事件写入新开放段不会丢)。
|
// 取段拼批(入选段即封段,冲刷中的新事件写入新开放段不会丢)。
|
||||||
final batch = _store.takeBatch(_maxBatchEvents);
|
final batch = _store.takeBatch(_maxBatchEvents);
|
||||||
if (batch.isEmpty) break;
|
if (batch.isEmpty) break;
|
||||||
final rejected = await _upload(batch.events);
|
final rejected = await uploadBatch(batch.events);
|
||||||
|
// 拿到服务端应答即连通性恢复,重置退避。
|
||||||
|
_resetBackoff();
|
||||||
// at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段;
|
// at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段;
|
||||||
// 4xx 批次计入本地丢弃诊断数。
|
// 4xx 批次计入本地丢弃诊断数。
|
||||||
await _store.removeSegments(batch.segmentIds, countAsDropped: rejected);
|
await _store.removeSegments(batch.segmentIds, countAsDropped: rejected);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 网络错误 / 5xx:段保留在持久化队列,等下次触发或冷启动重传。
|
// 网络错误 / 5xx:段保留在持久化队列,指数退避后由定时冲刷重试,
|
||||||
|
// 或等显式触发/冷启动重传。
|
||||||
|
_scheduleBackoff();
|
||||||
debugPrint('Analytics upload failed, events kept queued: $error');
|
debugPrint('Analytics upload failed, events kept queued: $error');
|
||||||
} finally {
|
} finally {
|
||||||
_flushing = false;
|
_flushing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _scheduleBackoff() {
|
||||||
|
final next = _backoffDelay == null ? _backoffInitial : _backoffDelay! * 2;
|
||||||
|
_backoffDelay = next > _backoffCap ? _backoffCap : next;
|
||||||
|
_retryNotBefore = _now().add(_backoffDelay!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetBackoff() {
|
||||||
|
_backoffDelay = null;
|
||||||
|
_retryNotBefore = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// 上传一批事件。返回 true 表示 4xx 永久拒绝(调用方删段并计丢弃);
|
/// 上传一批事件。返回 true 表示 4xx 永久拒绝(调用方删段并计丢弃);
|
||||||
/// 网络错误 / 5xx 抛异常(调用方保留段)。
|
/// 网络错误 / 5xx / 429 抛异常(调用方保留段并退避)。
|
||||||
Future<bool> _upload(List<Map<String, dynamic>> events) async {
|
/// protected:测试子类以假上传替换,免起真实 HttpServer。
|
||||||
|
@protected
|
||||||
|
@visibleForTesting
|
||||||
|
Future<bool> uploadBatch(List<Map<String, dynamic>> events) async {
|
||||||
final token = getAccessToken?.call();
|
final token = getAccessToken?.call();
|
||||||
final request =
|
final request =
|
||||||
await HttpClient().postUrl(Uri.parse('$apiBaseUrl/api/v1/events'))
|
await HttpClient().postUrl(Uri.parse('$apiBaseUrl/api/v1/events'))
|
||||||
@@ -173,6 +265,11 @@ class AnalyticsService {
|
|||||||
request.add(utf8.encode(jsonEncode({'events': events})));
|
request.add(utf8.encode(jsonEncode({'events': events})));
|
||||||
|
|
||||||
final response = await request.close();
|
final response = await request.close();
|
||||||
|
if (response.statusCode == 429) {
|
||||||
|
// 429 按网络错误同路径处理(保段 + 指数退避):后端限流尚未实现
|
||||||
|
// (iteration-2/09 出入项),Retry-After 分支待其落地后一并做。
|
||||||
|
throw Exception('Upload rate limited (429), events kept queued');
|
||||||
|
}
|
||||||
if (response.statusCode >= 400 && response.statusCode < 500) {
|
if (response.statusCode >= 400 && response.statusCode < 500) {
|
||||||
// 4xx 为永久性拒绝(校验失败/批量超限等),重试不可能成功;
|
// 4xx 为永久性拒绝(校验失败/批量超限等),重试不可能成功;
|
||||||
// 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。
|
// 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class SessionTracker with WidgetsBindingObserver {
|
|||||||
SessionTracker({
|
SessionTracker({
|
||||||
this.timeout = const Duration(minutes: 30),
|
this.timeout = const Duration(minutes: 30),
|
||||||
this.onLeaveForeground,
|
this.onLeaveForeground,
|
||||||
|
this.onEnterForeground,
|
||||||
DateTime Function()? now,
|
DateTime Function()? now,
|
||||||
}) : _now = now ?? DateTime.now,
|
}) : _now = now ?? DateTime.now,
|
||||||
_sessionId = const Uuid().v7();
|
_sessionId = const Uuid().v7();
|
||||||
@@ -25,6 +26,10 @@ class SessionTracker with WidgetsBindingObserver {
|
|||||||
/// 首次离开前台时回调(app 装配层用于触发埋点队列冲刷)。
|
/// 首次离开前台时回调(app 装配层用于触发埋点队列冲刷)。
|
||||||
final VoidCallback? onLeaveForeground;
|
final VoidCallback? onLeaveForeground;
|
||||||
|
|
||||||
|
/// 回到前台时回调(app 装配层用于恢复 30 秒定时冲刷);
|
||||||
|
/// 冷启动首个 resumed 不触发(此前未离开过前台)。
|
||||||
|
final VoidCallback? onEnterForeground;
|
||||||
|
|
||||||
/// 时钟注入口,测试免真实等待。
|
/// 时钟注入口,测试免真实等待。
|
||||||
final DateTime Function() _now;
|
final DateTime Function() _now;
|
||||||
|
|
||||||
@@ -39,9 +44,12 @@ class SessionTracker with WidgetsBindingObserver {
|
|||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
final leftAt = _leftForegroundAt;
|
final leftAt = _leftForegroundAt;
|
||||||
if (leftAt != null && _now().difference(leftAt) > timeout) {
|
if (leftAt != null) {
|
||||||
|
if (_now().difference(leftAt) > timeout) {
|
||||||
_sessionId = const Uuid().v7();
|
_sessionId = const Uuid().v7();
|
||||||
}
|
}
|
||||||
|
onEnterForeground?.call();
|
||||||
|
}
|
||||||
_leftForegroundAt = null;
|
_leftForegroundAt = null;
|
||||||
} else if (_lastState == AppLifecycleState.resumed) {
|
} else if (_lastState == AppLifecycleState.resumed) {
|
||||||
// 首次离开前台才记时;inactive/hidden/paused 级联不覆盖。
|
// 首次离开前台才记时;inactive/hidden/paused 级联不覆盖。
|
||||||
|
|||||||
+69
-2
@@ -12,6 +12,12 @@ import 'package:patbond_flutter/features/auth/auth_repository.dart';
|
|||||||
import 'package:patbond_flutter/features/auth/login_page.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/session_manager.dart';
|
||||||
import 'package:patbond_flutter/features/auth/splash_page.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/main/main_shell_page.dart';
|
||||||
import 'package:patbond_flutter/features/pets/health_record_analytics.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/pet_analytics.dart';
|
||||||
@@ -25,12 +31,18 @@ class App extends StatefulWidget {
|
|||||||
this.sessionManager,
|
this.sessionManager,
|
||||||
this.authRepository,
|
this.authRepository,
|
||||||
this.petsRepository,
|
this.petsRepository,
|
||||||
|
this.communityRepository,
|
||||||
|
this.mediaUploaderFactory,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 测试注入口;生产默认走安全存储 + 真实 API。
|
/// 测试注入口;生产默认走安全存储 + 真实 API。
|
||||||
final SessionManager? sessionManager;
|
final SessionManager? sessionManager;
|
||||||
final AuthRepository? authRepository;
|
final AuthRepository? authRepository;
|
||||||
final PetsRepository? petsRepository;
|
final PetsRepository? petsRepository;
|
||||||
|
final CommunityRepository? communityRepository;
|
||||||
|
|
||||||
|
/// 发布页媒体上传器构造口(桌面实测替换选图/压缩层;生产为 null)。
|
||||||
|
final MediaUploaderFactory? mediaUploaderFactory;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<App> createState() => _AppState();
|
State<App> createState() => _AppState();
|
||||||
@@ -41,8 +53,12 @@ class _AppState extends State<App> {
|
|||||||
late final SessionManager sessionManager;
|
late final SessionManager sessionManager;
|
||||||
late final AuthRepository authRepository;
|
late final AuthRepository authRepository;
|
||||||
late final PetsController petsController;
|
late final PetsController petsController;
|
||||||
|
late final CommunityController communityController;
|
||||||
late final PetAnalytics petAnalytics;
|
late final PetAnalytics petAnalytics;
|
||||||
late final HealthRecordAnalytics healthRecordAnalytics;
|
late final HealthRecordAnalytics healthRecordAnalytics;
|
||||||
|
late final FeedAnalytics feedAnalytics;
|
||||||
|
late final CommunityInteractionAnalytics interactionAnalytics;
|
||||||
|
late final PostAnalytics postAnalytics;
|
||||||
late final SessionTracker _sessionTracker;
|
late final SessionTracker _sessionTracker;
|
||||||
late final AnalyticsService _analytics;
|
late final AnalyticsService _analytics;
|
||||||
late final PageViewTracker _pageViewTracker;
|
late final PageViewTracker _pageViewTracker;
|
||||||
@@ -58,7 +74,12 @@ class _AppState extends State<App> {
|
|||||||
SessionManager(store: const SecureTokenStore());
|
SessionManager(store: const SecureTokenStore());
|
||||||
|
|
||||||
_sessionTracker = SessionTracker(
|
_sessionTracker = SessionTracker(
|
||||||
onLeaveForeground: () => _analytics.flushNow(),
|
onLeaveForeground: () {
|
||||||
|
// 退后台:停定时冲刷并立即冲刷一次(既有触发点保留)。
|
||||||
|
_analytics.stopPeriodicFlush();
|
||||||
|
_analytics.flushNow();
|
||||||
|
},
|
||||||
|
onEnterForeground: () => _analytics.startPeriodicFlush(),
|
||||||
);
|
);
|
||||||
WidgetsBinding.instance.addObserver(_sessionTracker);
|
WidgetsBinding.instance.addObserver(_sessionTracker);
|
||||||
|
|
||||||
@@ -70,6 +91,8 @@ class _AppState extends State<App> {
|
|||||||
_initAppVersion();
|
_initAppVersion();
|
||||||
// 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。
|
// 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。
|
||||||
_analytics.restore();
|
_analytics.restore();
|
||||||
|
// 前台期间 30 秒定时冲刷(13 号 §3.4 第 4 触发点)。
|
||||||
|
_analytics.startPeriodicFlush();
|
||||||
|
|
||||||
_pageViewTracker = PageViewTracker(_analytics.trackEvent);
|
_pageViewTracker = PageViewTracker(_analytics.trackEvent);
|
||||||
_routeObserver = AnalyticsRouteObserver(
|
_routeObserver = AnalyticsRouteObserver(
|
||||||
@@ -81,8 +104,18 @@ class _AppState extends State<App> {
|
|||||||
petsController = PetsController(
|
petsController = PetsController(
|
||||||
repository: widget.petsRepository ?? _buildPetsRepository(),
|
repository: widget.petsRepository ?? _buildPetsRepository(),
|
||||||
);
|
);
|
||||||
|
// T3-14:Feed segment 接线主壳(数据层 T3-12 就位);T3-16 起
|
||||||
|
// 点赞/收藏成功埋点经 interactionAnalytics 在 controller 内上报。
|
||||||
|
interactionAnalytics = CommunityInteractionAnalytics(_analytics.trackEvent);
|
||||||
|
communityController = CommunityController(
|
||||||
|
repository: widget.communityRepository ?? _buildCommunityRepository(),
|
||||||
|
interactionAnalytics: interactionAnalytics,
|
||||||
|
);
|
||||||
petAnalytics = PetAnalytics(_analytics.trackEvent);
|
petAnalytics = PetAnalytics(_analytics.trackEvent);
|
||||||
healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent);
|
healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent);
|
||||||
|
feedAnalytics = FeedAnalytics(_analytics.trackEvent);
|
||||||
|
// T3-17:发布漏斗五事件 + 媒体上传三段(发布页与 MediaUploader 消费)。
|
||||||
|
postAnalytics = PostAnalytics(_analytics.trackEvent);
|
||||||
|
|
||||||
// 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件)
|
// 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件)
|
||||||
sessionManager.addListener(_reportAuthStateChange);
|
sessionManager.addListener(_reportAuthStateChange);
|
||||||
@@ -136,6 +169,31 @@ class _AppState extends State<App> {
|
|||||||
return ApiPetsRepository(api: api);
|
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() {
|
AnalyticsPageName? _resolveRootPage() {
|
||||||
// 回栈到无名根路由时解析当前认证状态页/主壳 Tab
|
// 回栈到无名根路由时解析当前认证状态页/主壳 Tab
|
||||||
return switch (sessionManager.status) {
|
return switch (sessionManager.status) {
|
||||||
@@ -146,9 +204,10 @@ class _AppState extends State<App> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _reportAuthStateChange() {
|
void _reportAuthStateChange() {
|
||||||
// 登出即清宠物档案内存副本(跨账号不泄漏;重登后列表页重新拉取)。
|
// 登出即清宠物档案与社区 Feed 内存副本(跨账号不泄漏;重登后重新拉取)。
|
||||||
if (sessionManager.status == AuthStatus.unauthenticated) {
|
if (sessionManager.status == AuthStatus.unauthenticated) {
|
||||||
petsController.reset();
|
petsController.reset();
|
||||||
|
communityController.reset();
|
||||||
}
|
}
|
||||||
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
|
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
|
||||||
final page = switch (sessionManager.status) {
|
final page = switch (sessionManager.status) {
|
||||||
@@ -161,10 +220,12 @@ class _AppState extends State<App> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_analytics.stopPeriodicFlush();
|
||||||
sessionManager.removeListener(_reportAuthStateChange);
|
sessionManager.removeListener(_reportAuthStateChange);
|
||||||
WidgetsBinding.instance.removeObserver(_sessionTracker);
|
WidgetsBinding.instance.removeObserver(_sessionTracker);
|
||||||
appState.dispose();
|
appState.dispose();
|
||||||
petsController.dispose();
|
petsController.dispose();
|
||||||
|
communityController.dispose();
|
||||||
if (widget.sessionManager == null) sessionManager.dispose();
|
if (widget.sessionManager == null) sessionManager.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -187,8 +248,14 @@ class _AppState extends State<App> {
|
|||||||
key: const ValueKey('shell'),
|
key: const ValueKey('shell'),
|
||||||
appState: appState,
|
appState: appState,
|
||||||
petsController: petsController,
|
petsController: petsController,
|
||||||
|
communityController: communityController,
|
||||||
|
currentUserId: sessionManager.userId,
|
||||||
petAnalytics: petAnalytics,
|
petAnalytics: petAnalytics,
|
||||||
healthRecordAnalytics: healthRecordAnalytics,
|
healthRecordAnalytics: healthRecordAnalytics,
|
||||||
|
feedAnalytics: feedAnalytics,
|
||||||
|
interactionAnalytics: interactionAnalytics,
|
||||||
|
postAnalytics: postAnalytics,
|
||||||
|
mediaUploaderFactory: widget.mediaUploaderFactory,
|
||||||
pageViewTracker: _pageViewTracker,
|
pageViewTracker: _pageViewTracker,
|
||||||
onLogout: authRepository.logout,
|
onLogout: authRepository.logout,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// cursor 分页正典信封 `{items, nextCursor, hasMore}`(pets 域体重 / 健康事件,
|
||||||
|
/// community 域 Feed / 我的帖子 / 评论 / 收藏共用;自 pet_models.dart 上移至 core)。
|
||||||
|
class CursorPage<T> {
|
||||||
|
const CursorPage({
|
||||||
|
required this.items,
|
||||||
|
required this.nextCursor,
|
||||||
|
required this.hasMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CursorPage.fromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
T Function(Map<String, dynamic>) itemFromJson,
|
||||||
|
) {
|
||||||
|
return CursorPage(
|
||||||
|
items: (json['items'] as List)
|
||||||
|
.map((item) => itemFromJson(item as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
// 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。
|
||||||
|
nextCursor: json['nextCursor'] as String?,
|
||||||
|
hasMore: json['hasMore'] as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<T> items;
|
||||||
|
final String? nextCursor;
|
||||||
|
final bool hasMore;
|
||||||
|
}
|
||||||
@@ -25,6 +25,14 @@ const String patbondPetApiBaseUrl = String.fromEnvironment(
|
|||||||
defaultValue: 'http://127.0.0.1:8083',
|
defaultValue: 'http://127.0.0.1:8083',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// community 服务基地址(/api/v1/posts、/api/v1/feed、/api/v1/media 等
|
||||||
|
/// community/media 域 13 路径):沿用分端口直连模式,
|
||||||
|
/// `--dart-define=PATBOND_COMMUNITY_API_BASE_URL=...` 注入。
|
||||||
|
const String patbondCommunityApiBaseUrl = String.fromEnvironment(
|
||||||
|
'PATBOND_COMMUNITY_API_BASE_URL',
|
||||||
|
defaultValue: 'http://127.0.0.1:8084',
|
||||||
|
);
|
||||||
|
|
||||||
/// 构建全局共用的 Dio 实例。
|
/// 构建全局共用的 Dio 实例。
|
||||||
///
|
///
|
||||||
/// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成
|
/// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成
|
||||||
|
|||||||
@@ -48,6 +48,36 @@ abstract final class ApiCodes {
|
|||||||
|
|
||||||
/// 提醒状态机 / completed-completedAt 一致性违反(HTTP 422)。
|
/// 提醒状态机 / completed-completedAt 一致性违反(HTTP 422)。
|
||||||
static const careReminderRuleViolation = 42202;
|
static const careReminderRuleViolation = 42202;
|
||||||
|
|
||||||
|
// ------ community / media 域(契约 v1.3.0 冻结,M3 第二波定型 9 个)------
|
||||||
|
|
||||||
|
/// 对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论)。
|
||||||
|
static const postAccessDenied = 40301;
|
||||||
|
|
||||||
|
/// 帖子不存在 / 已软删 / hidden/archived(含作者)/ 他人 draft
|
||||||
|
/// (防枚举,全部情况响应一致;互动路径上含作者本人草稿)。
|
||||||
|
static const postNotFound = 40403;
|
||||||
|
|
||||||
|
/// 评论不存在、已删或所属帖子不可见(防枚举合并)。
|
||||||
|
static const commentNotFound = 40404;
|
||||||
|
|
||||||
|
/// media asset 不存在、非本人所有或已删(防枚举合并)。
|
||||||
|
static const mediaNotFound = 40405;
|
||||||
|
|
||||||
|
/// 目标用户不存在或已注销(合并不泄露成因)。
|
||||||
|
static const communityUserNotFound = 40406;
|
||||||
|
|
||||||
|
/// 同 Idempotency-Key 不同 payload(规范化 request_hash 不符)。
|
||||||
|
static const idempotencyKeyMismatch = 40905;
|
||||||
|
|
||||||
|
/// 引用了本人所有但非 ready(uploading/failed)状态的 asset(HTTP 422)。
|
||||||
|
static const mediaNotReady = 42203;
|
||||||
|
|
||||||
|
/// 自关注(仅 PUT;自取关走 DELETE 的 200 幂等 no-op)。
|
||||||
|
static const selfFollow = 42204;
|
||||||
|
|
||||||
|
/// 上传状态不允许确认(非 uploading 态或对象校验未通过,HTTP 422)。
|
||||||
|
static const mediaUploadStateInvalid = 42205;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API 调用的类型化异常。页面按类型映射为三层错误呈现
|
/// API 调用的类型化异常。页面按类型映射为三层错误呈现
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
|
||||||
|
/// 预签名 URL 的图片内存缓存 key:剥离 SigV4 签名参数(`X-Amz-*`,
|
||||||
|
/// 大小写不敏感),保留其余 query。
|
||||||
|
///
|
||||||
|
/// 媒体 URL 每次响应现签(TTL 1 小时),同一对象两次响应的完整 URL
|
||||||
|
/// 必然不同;若按完整 URL 作缓存 key,同图会反复未命中、重复下载。
|
||||||
|
/// 对象路径唯一标识 MinIO 对象,剥签名后即稳定 key。
|
||||||
|
String presignedImageCacheKey(String url) {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || uri.queryParameters.isEmpty) return url;
|
||||||
|
final kept = <String, String>{};
|
||||||
|
var stripped = false;
|
||||||
|
uri.queryParameters.forEach((key, value) {
|
||||||
|
if (key.toLowerCase().startsWith('x-amz-')) {
|
||||||
|
stripped = true;
|
||||||
|
} else {
|
||||||
|
kept[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!stripped) return url;
|
||||||
|
// Uri.replace 的 null 语义是「保留原值」,全剥空时手工截断 query。
|
||||||
|
if (kept.isEmpty) return url.substring(0, url.indexOf('?'));
|
||||||
|
return uri.replace(queryParameters: kept).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 以剥签名 key 判等的网络图 provider:同对象的不同签名 URL 命中同一
|
||||||
|
/// [ImageCache] 条目;未命中时仍以完整签名 URL 发起请求(委托
|
||||||
|
/// [NetworkImage] 加载,其为 factory-only 接口,无法直接继承)。
|
||||||
|
class SignedNetworkImage extends ImageProvider<SignedNetworkImage> {
|
||||||
|
SignedNetworkImage(this.url, {this.scale = 1.0})
|
||||||
|
: cacheKey = presignedImageCacheKey(url);
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
final double scale;
|
||||||
|
|
||||||
|
/// 剥离签名参数后的稳定缓存 key。
|
||||||
|
final String cacheKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SignedNetworkImage> obtainKey(ImageConfiguration configuration) =>
|
||||||
|
SynchronousFuture<SignedNetworkImage>(this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageStreamCompleter loadImage(
|
||||||
|
SignedNetworkImage key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) {
|
||||||
|
final delegate = NetworkImage(key.url, scale: key.scale);
|
||||||
|
return delegate.loadImage(delegate, decode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other.runtimeType != runtimeType) return false;
|
||||||
|
return other is SignedNetworkImage &&
|
||||||
|
other.cacheKey == cacheKey &&
|
||||||
|
other.scale == scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(cacheKey, scale);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SignedNetworkImage("$url", cacheKey: "$cacheKey")';
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
|
||||||
|
/// 评论条目(05 号规范 §3.4,demo 气泡形态升共享):
|
||||||
|
/// `PetAvatar sm32` + 10 + 气泡(`surface` 底、`border` 1px、圆角 16、
|
||||||
|
/// padding 12)——作者名 13/w700 → 4 → 内容 bodyMedium → 6 → 底行
|
||||||
|
/// (时间 11 `inkSoft` + 仅本人评论的「删除」入口)。
|
||||||
|
///
|
||||||
|
/// - @ 回复(单层平铺,契约 replyToUser)以「回复 @昵称:」前缀呈现;
|
||||||
|
/// 降级作者统一「宠友」占位(isDegraded 一个判定口)。
|
||||||
|
/// - [onDelete] 非 null 才渲染删除入口——**权限判定在调用方**(17 号
|
||||||
|
/// 后端语义:仅评论作者可删,他人可见评论 403/40301);[deleting]
|
||||||
|
/// 期间入口替换为 14 转圈防重复提交。
|
||||||
|
/// - 评论点赞(§3.4 底行右端)无契约端点,M3 不渲染。
|
||||||
|
class CommentTile extends StatelessWidget {
|
||||||
|
const CommentTile({
|
||||||
|
required this.comment,
|
||||||
|
super.key,
|
||||||
|
this.onDelete,
|
||||||
|
this.deleting = false,
|
||||||
|
this.now,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PostComment comment;
|
||||||
|
|
||||||
|
/// 删除回调;null = 非本人评论,不渲染删除入口。
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
|
/// 删除请求在途(入口转圈锁定)。
|
||||||
|
final bool deleting;
|
||||||
|
|
||||||
|
/// 相对时间的参考时钟(测试注入;缺省取当前时间)。
|
||||||
|
final DateTime? now;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final replyTo = comment.replyToUser;
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
PetAvatar(size: PetAvatarSize.sm, url: comment.author.avatarUrl),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
authorDisplayName(comment.author),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
if (replyTo != null)
|
||||||
|
TextSpan(
|
||||||
|
text: '回复 @${authorDisplayName(replyTo)}:',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(text: comment.content),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
feedRelativeTime(comment.createdAt, now: now),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (onDelete != null)
|
||||||
|
deleting
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(4),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Semantics(
|
||||||
|
label: '删除评论',
|
||||||
|
button: true,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onDelete,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppRadius.pill,
|
||||||
|
),
|
||||||
|
// 视觉 11 字,触控由 padding 撑到 ≥32
|
||||||
|
//(气泡内行高受限,不足 44 以热区扩展补偿)。
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'删除',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Feed 骨架屏单元(05 号规范 §3.7):模拟单图卡——头部行(32 圆 +
|
||||||
|
/// 两条横条)→ 4:3 通栏块 → 两条正文横条。块色 `surfaceTint`
|
||||||
|
/// (1.18:1,装饰性占位不受对比度约束)。
|
||||||
|
///
|
||||||
|
/// 动效:整体不透明度 0.6 ↔ 1.0 呼吸循环 1200ms;系统「减弱动态效果」
|
||||||
|
/// 开启时静止在 1.0。首载用法为连排 3 张(P1/P4)。
|
||||||
|
class FeedSkeleton extends StatefulWidget {
|
||||||
|
const FeedSkeleton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FeedSkeleton> createState() => _FeedSkeletonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedSkeletonState extends State<FeedSkeleton>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1200),
|
||||||
|
lowerBound: 0.6,
|
||||||
|
value: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final reduceMotion = MediaQuery.of(context).disableAnimations;
|
||||||
|
if (reduceMotion) {
|
||||||
|
_controller.stop();
|
||||||
|
_controller.value = 1;
|
||||||
|
} else if (!_controller.isAnimating) {
|
||||||
|
_controller.repeat(reverse: true);
|
||||||
|
}
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: _controller,
|
||||||
|
child: Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const _SkeletonBlock(width: 32, height: 32, circle: true),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_SkeletonBlock(
|
||||||
|
width: _relative(context, 0.40),
|
||||||
|
height: 12,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_SkeletonBlock(
|
||||||
|
width: _relative(context, 0.24),
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const AspectRatio(
|
||||||
|
aspectRatio: 4 / 3,
|
||||||
|
child: ColoredBox(color: AppColors.surfaceTint),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_SkeletonBlock(width: _relative(context, 0.90), height: 12),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_SkeletonBlock(width: _relative(context, 0.60), height: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 横条宽度按屏宽比例取(规范 40%/24%/90%/60%),免依赖父约束测量。
|
||||||
|
double _relative(BuildContext context, double fraction) =>
|
||||||
|
MediaQuery.of(context).size.width * fraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SkeletonBlock extends StatelessWidget {
|
||||||
|
const _SkeletonBlock({
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
this.circle = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final bool circle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceTint,
|
||||||
|
borderRadius: BorderRadius.circular(circle ? width / 2 : 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// 点赞 / 收藏按钮语义变体(05 号规范 §3.5:同一组件,图标与语义色
|
||||||
|
/// 参数化)。点赞激活 = `error` 图标 + `errorDark` 计数(demo 的
|
||||||
|
/// `Colors.red` 3.13:1 修订弃用,D6);收藏激活 = `accentDark` 同色。
|
||||||
|
enum LikeButtonVariant {
|
||||||
|
like(
|
||||||
|
inactiveIcon: Icons.favorite_border,
|
||||||
|
activeIcon: Icons.favorite,
|
||||||
|
activeIconColor: AppColors.error,
|
||||||
|
activeCountColor: AppColors.errorDark,
|
||||||
|
),
|
||||||
|
bookmark(
|
||||||
|
inactiveIcon: Icons.bookmark_border,
|
||||||
|
activeIcon: Icons.bookmark,
|
||||||
|
activeIconColor: AppColors.accentDark,
|
||||||
|
activeCountColor: AppColors.accentDark,
|
||||||
|
);
|
||||||
|
|
||||||
|
const LikeButtonVariant({
|
||||||
|
required this.inactiveIcon,
|
||||||
|
required this.activeIcon,
|
||||||
|
required this.activeIconColor,
|
||||||
|
required this.activeCountColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData inactiveIcon;
|
||||||
|
final IconData activeIcon;
|
||||||
|
final Color activeIconColor;
|
||||||
|
final Color activeCountColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞/收藏交互钮(05 号规范 §3.5):图标 20 + 计数 13/w600,未激活
|
||||||
|
/// 一律 `inkSoft`(6.59:1)。触控 44×44 由 padding 撑足。
|
||||||
|
///
|
||||||
|
/// 乐观更新视觉(§3.5/§4 三层闪烁抑制的 UI 半边,状态本体由持有方经
|
||||||
|
/// ToggleSync 驱动):
|
||||||
|
///
|
||||||
|
/// - **点按驱动**的状态变化:激活播 240ms 弹性缩放(1→1.25→1)+ 图标
|
||||||
|
/// 120ms 淡入;取消仅 120ms 颜色渐出、无缩放。
|
||||||
|
/// - **非点按驱动**的状态变化(失败回滚 / 服务端对账):零动画直接跳变;
|
||||||
|
/// 若激活动画未播完,等播完再跳(避免动画中途反转的抖动,§4.3a)。
|
||||||
|
/// - 计数变化一律直接替换,不做滚动动画(回滚时无二次滚动)。
|
||||||
|
/// - 系统「减弱动态效果」开启时全部降级为瞬变。
|
||||||
|
///
|
||||||
|
/// [onPressed] 传 null 即纯展示禁用态(仍按正常色渲染计数与状态)。
|
||||||
|
class LikeButton extends StatefulWidget {
|
||||||
|
const LikeButton({
|
||||||
|
required this.variant,
|
||||||
|
required this.active,
|
||||||
|
required this.count,
|
||||||
|
super.key,
|
||||||
|
this.onPressed,
|
||||||
|
this.semanticLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final LikeButtonVariant variant;
|
||||||
|
final bool active;
|
||||||
|
final int count;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final String? semanticLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LikeButton> createState() => _LikeButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LikeButtonState extends State<LikeButton>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _scaleController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 240),
|
||||||
|
);
|
||||||
|
late final Animation<double> _scale = TweenSequence<double>([
|
||||||
|
TweenSequenceItem(
|
||||||
|
tween: Tween<double>(
|
||||||
|
begin: 1,
|
||||||
|
end: 1.25,
|
||||||
|
).chain(CurveTween(curve: Curves.easeOut)),
|
||||||
|
weight: 40,
|
||||||
|
),
|
||||||
|
TweenSequenceItem(
|
||||||
|
tween: Tween<double>(
|
||||||
|
begin: 1.25,
|
||||||
|
end: 1,
|
||||||
|
).chain(CurveTween(curve: Curves.easeOutBack)),
|
||||||
|
weight: 60,
|
||||||
|
),
|
||||||
|
]).animate(_scaleController);
|
||||||
|
|
||||||
|
/// 当前展示态(回滚等待动画播完期间可短暂落后于 widget.active)。
|
||||||
|
late bool _displayActive = widget.active;
|
||||||
|
|
||||||
|
/// 展示计数与状态成对更新(§4.3c:回滚不出现「心已灭计数未减」中间帧)。
|
||||||
|
late int _displayCount = widget.count;
|
||||||
|
|
||||||
|
/// 最近一次点按的期望目标态;didUpdateWidget 以此区分「点按驱动」
|
||||||
|
/// (播动画)与「回滚/对账」(零动画跳变)。
|
||||||
|
bool? _expectedTarget;
|
||||||
|
|
||||||
|
/// 图标切换是否走 120ms 淡入淡出(点按驱动);回滚跳变置 false。
|
||||||
|
bool _fadeSwap = false;
|
||||||
|
|
||||||
|
bool get _reduceMotion =>
|
||||||
|
MediaQuery.maybeOf(context)?.disableAnimations ?? false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(LikeButton oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.active == widget.active) {
|
||||||
|
// 状态未变的计数变化 = 服务端对账:静默替换、不播动画(§4.4)。
|
||||||
|
_displayCount = widget.count;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final tapDriven = _expectedTarget == widget.active;
|
||||||
|
_expectedTarget = null;
|
||||||
|
if (tapDriven && !_reduceMotion) {
|
||||||
|
_fadeSwap = true;
|
||||||
|
_displayActive = widget.active;
|
||||||
|
_displayCount = widget.count;
|
||||||
|
if (widget.active) _scaleController.forward(from: 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 回滚 / 对账:零动画、计数与状态成对跳变。激活动画未播完则等
|
||||||
|
// 播完再跳(§4.3a,避免动画中途反转的抖动)。
|
||||||
|
_fadeSwap = false;
|
||||||
|
if (_scaleController.isAnimating) {
|
||||||
|
_scaleController.forward().whenComplete(() {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_displayActive = widget.active;
|
||||||
|
_displayCount = widget.count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_displayActive = widget.active;
|
||||||
|
_displayCount = widget.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap() {
|
||||||
|
_expectedTarget = !widget.active;
|
||||||
|
widget.onPressed!();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scaleController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final variant = widget.variant;
|
||||||
|
final active = _displayActive;
|
||||||
|
final iconColor = active ? variant.activeIconColor : AppColors.inkSoft;
|
||||||
|
final countColor = active ? variant.activeCountColor : AppColors.inkSoft;
|
||||||
|
return Semantics(
|
||||||
|
label: widget.semanticLabel,
|
||||||
|
button: widget.onPressed != null,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: widget.onPressed == null ? null : _handleTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ScaleTransition(
|
||||||
|
scale: _scale,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: _fadeSwap
|
||||||
|
? const Duration(milliseconds: 120)
|
||||||
|
: Duration.zero,
|
||||||
|
child: Icon(
|
||||||
|
active ? variant.activeIcon : variant.inactiveIcon,
|
||||||
|
key: ValueKey(active),
|
||||||
|
size: 20,
|
||||||
|
color: iconColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
// 计数直接替换(§3.5:不做滚动动画,避免回滚二次滚动)。
|
||||||
|
Text(
|
||||||
|
'$_displayCount',
|
||||||
|
style: TextStyle(
|
||||||
|
color: countColor,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/like_button.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
/// 帖子卡三形态(05 号规范 §2.1/§3.1):home 私有 `_PostCard` 的升级
|
||||||
|
/// 迁移,按媒体形态分支——
|
||||||
|
///
|
||||||
|
/// | 形态 | 媒体区 |
|
||||||
|
/// | --- | --- |
|
||||||
|
/// | 单图(mediaCount ≤ 1 且有封面) | 通栏出血 4:3 |
|
||||||
|
/// | 多图(mediaCount > 1) | [PostMediaGrid] 折叠封面(FeedCard 契约只带封面 + 计数),水平 padding 14 |
|
||||||
|
/// | 纯文字(无封面) | 无媒体区,正文放宽 ≤6 行、15/1.6 |
|
||||||
|
///
|
||||||
|
/// 头部行:作者头像 32 + 名字 14/w700 + 时间 12 `inkSoft`;求助帖
|
||||||
|
/// 元信息尾追加 `TagPill(accent)`。降级作者([AuthorSummary.isDegraded])
|
||||||
|
/// 渲染占位头像 + 「宠友」。操作行:[LikeButton](点赞 / 收藏)+ 评论
|
||||||
|
/// 计数 + 分享,各钮触控 44;互动回调传 null 即纯展示(T3-14 形态,
|
||||||
|
/// ToggleSync 接线属 T3-15/16)。
|
||||||
|
class PostCard extends StatelessWidget {
|
||||||
|
const PostCard({
|
||||||
|
required this.card,
|
||||||
|
super.key,
|
||||||
|
this.onTap,
|
||||||
|
this.onLikeTap,
|
||||||
|
this.onCommentTap,
|
||||||
|
this.onBookmarkTap,
|
||||||
|
this.onShareTap,
|
||||||
|
this.now,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FeedCard card;
|
||||||
|
|
||||||
|
/// 整卡点按(帖子详情入口)。
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
final VoidCallback? onLikeTap;
|
||||||
|
final VoidCallback? onCommentTap;
|
||||||
|
final VoidCallback? onBookmarkTap;
|
||||||
|
final VoidCallback? onShareTap;
|
||||||
|
|
||||||
|
/// 相对时间的参考时钟(测试注入;缺省取当前时间)。
|
||||||
|
final DateTime? now;
|
||||||
|
|
||||||
|
bool get _isTextOnly => card.coverImage == null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_header(context),
|
||||||
|
if (card.coverImage != null)
|
||||||
|
card.mediaCount > 1
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
child: PostMediaGrid(
|
||||||
|
urls: [card.coverImage!.url],
|
||||||
|
totalCount: card.mediaCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: AspectRatio(
|
||||||
|
aspectRatio: 4 / 3,
|
||||||
|
child: RemoteImage(url: card.coverImage!.url),
|
||||||
|
),
|
||||||
|
_body(context),
|
||||||
|
_actionRow(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _header(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
PetAvatar(size: PetAvatarSize.sm, url: card.author.avatarUrl),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
authorDisplayName(card.author),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
feedRelativeTime(card.publishedAt, now: now),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (card.category == PostCategory.help) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const TagPill('求助', color: AppColors.accent),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _body(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (card.title != null) ...[
|
||||||
|
Text(
|
||||||
|
card.title!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
card.contentPreview,
|
||||||
|
// 纯文字帖放宽至 6 行并升字号补偿视觉重量(§2.1)。
|
||||||
|
maxLines: _isTextOnly ? 6 : 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: _isTextOnly
|
||||||
|
? const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.6,
|
||||||
|
)
|
||||||
|
: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _actionRow() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
LikeButton(
|
||||||
|
variant: LikeButtonVariant.like,
|
||||||
|
active: card.likedByMe,
|
||||||
|
count: card.likeCount,
|
||||||
|
onPressed: onLikeTap,
|
||||||
|
semanticLabel: '点赞',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_CommentCount(count: card.commentCount, onPressed: onCommentTap),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
LikeButton(
|
||||||
|
variant: LikeButtonVariant.bookmark,
|
||||||
|
active: card.bookmarkedByMe,
|
||||||
|
count: card.bookmarkCount,
|
||||||
|
onPressed: onBookmarkTap,
|
||||||
|
semanticLabel: '收藏',
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
InkWell(
|
||||||
|
onTap: onShareTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||||
|
child: const SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: Icon(
|
||||||
|
Icons.ios_share_outlined,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论计数钮(与 LikeButton 同视觉规格,无激活态)。
|
||||||
|
class _CommentCount extends StatelessWidget {
|
||||||
|
const _CommentCount({required this.count, this.onPressed});
|
||||||
|
|
||||||
|
final int count;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.chat_bubble_outline,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'$count',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/upload_progress_overlay.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
/// 图片九宫格展示态(05 号规范 §3.2)。
|
||||||
|
///
|
||||||
|
/// - 列数规则:2、4 图 → 2 列;3、5–9 图 → 3 列;全部 1:1 cover、
|
||||||
|
/// 格间距 4、单格圆角 `sm`(12),`RemoteImage` 复用(loading tint 块 /
|
||||||
|
/// 失败图标兜底,缓存 key 已剥签名)。
|
||||||
|
/// - 超出折叠:最多显 9 格,[totalCount] 超过显示数时末格叠
|
||||||
|
/// `ink` 80% scrim + 白字「+N」20/w800(合成最亮白图 7.10:1;60% 档
|
||||||
|
/// 3.88:1 不达标弃用,§5.1 精算)。
|
||||||
|
/// - 单图折叠形态:Feed 卡片契约只带封面 + mediaCount(FeedCard 裁剪),
|
||||||
|
/// [urls] 单元素而 [totalCount] > 1 时渲染 4:3 单格 + 右下「+N」角标
|
||||||
|
/// 胶囊(同 80% scrim 精算)。
|
||||||
|
///
|
||||||
|
/// 编辑态(「+」格 / 删除角标 / 进度覆盖层)见同文件 [PostMediaEditGrid]
|
||||||
|
/// (T3-17 发布页选图区)。
|
||||||
|
class PostMediaGrid extends StatelessWidget {
|
||||||
|
const PostMediaGrid({
|
||||||
|
required this.urls,
|
||||||
|
super.key,
|
||||||
|
this.totalCount,
|
||||||
|
this.onCellTap,
|
||||||
|
}) : assert(urls.length > 0, 'PostMediaGrid 至少一张图');
|
||||||
|
|
||||||
|
/// 可展示的图片 URL(Feed 卡片仅封面一张;详情页全量)。
|
||||||
|
final List<String> urls;
|
||||||
|
|
||||||
|
/// 实际总张数(缺省 = urls.length);大于可展示数时渲染「+N」。
|
||||||
|
final int? totalCount;
|
||||||
|
|
||||||
|
/// 格子点按(全屏浏览入口,T3-15 详情页接线)。
|
||||||
|
final ValueChanged<int>? onCellTap;
|
||||||
|
|
||||||
|
static const _maxCells = 9;
|
||||||
|
static const _spacing = 4.0;
|
||||||
|
|
||||||
|
int get _effectiveTotal => totalCount ?? urls.length;
|
||||||
|
|
||||||
|
/// 列数规则(§3.2):2、4 → 2 列;其余 → 3 列(1 图不走网格)。
|
||||||
|
static int columnsFor(int cellCount) =>
|
||||||
|
(cellCount == 2 || cellCount == 4) ? 2 : 3;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (urls.length == 1) {
|
||||||
|
return _CollapsedCover(
|
||||||
|
url: urls.first,
|
||||||
|
hiddenCount: _effectiveTotal - 1,
|
||||||
|
onTap: onCellTap == null ? null : () => onCellTap!(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cellCount = urls.length > _maxCells ? _maxCells : urls.length;
|
||||||
|
final overflow = _effectiveTotal - cellCount;
|
||||||
|
final columns = columnsFor(cellCount);
|
||||||
|
return GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: columns,
|
||||||
|
mainAxisSpacing: _spacing,
|
||||||
|
crossAxisSpacing: _spacing,
|
||||||
|
),
|
||||||
|
itemCount: cellCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final isOverflowCell = overflow > 0 && index == cellCount - 1;
|
||||||
|
Widget cell = RemoteImage(
|
||||||
|
url: urls[index],
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
);
|
||||||
|
if (isOverflowCell) {
|
||||||
|
cell = Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
cell,
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.ink.withAlpha(204),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'+$overflow',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (onCellTap != null) {
|
||||||
|
cell = InkWell(
|
||||||
|
onTap: () => onCellTap!(index),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
child: cell,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cell;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑态九宫格(05 号规范 §3.2 编辑态;T3-17 发布页选图区)。
|
||||||
|
///
|
||||||
|
/// 与展示态 [PostMediaGrid] 同文件成组,但**独立成类**:展示态以
|
||||||
|
/// 「至少一张图 + URL 列表」为前提(构造断言),编辑态的常态却是
|
||||||
|
/// 「零张图 + 一个『+』格」,共用一个构造签名只会让两边都别扭。
|
||||||
|
///
|
||||||
|
/// - 固定 3 列(九宫格语义),格间距 4、圆角 `sm`(12)、1:1 `cover`;
|
||||||
|
/// 缩略图直接渲染选图原始字节([MediaUploadItem.previewBytes],
|
||||||
|
/// 不落磁盘、不走网络;解码失败回落 surfaceTint 块 + pets 图标)。
|
||||||
|
/// - 每格叠 [UploadProgressOverlay](六态映射四视觉态);可重试失败格
|
||||||
|
/// 整格点按重试,终态失败不给重试通栏。
|
||||||
|
/// - 删除角标:右上 22 圆 `ink` 80% 实底 + 白 close 14,触控热区 32。
|
||||||
|
/// - 「+」格:虚线 1.5px 圆角 12 + `add_photo_alternate_outlined` 24
|
||||||
|
/// `inkSoft`;[canAdd] 为 false(满 9 张)时隐藏。
|
||||||
|
/// - 长按拖拽排序未做(05 §6 D9 可选项):删格即整组重排,position 由
|
||||||
|
/// [MediaUploader.buildAttachRequests] 按当前列表序重发号。
|
||||||
|
class PostMediaEditGrid extends StatelessWidget {
|
||||||
|
const PostMediaEditGrid({
|
||||||
|
required this.items,
|
||||||
|
super.key,
|
||||||
|
this.canAdd = true,
|
||||||
|
this.onAdd,
|
||||||
|
this.onRemove,
|
||||||
|
this.onRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 当前上传项快照(顺序 = position 语义)。
|
||||||
|
final List<MediaUploadItem> items;
|
||||||
|
|
||||||
|
/// 是否渲染「+」格(剩余槽位 > 0)。
|
||||||
|
final bool canAdd;
|
||||||
|
|
||||||
|
final VoidCallback? onAdd;
|
||||||
|
|
||||||
|
/// 删格回调,参数为 [MediaUploadItem.localId]。
|
||||||
|
final ValueChanged<int>? onRemove;
|
||||||
|
|
||||||
|
/// 重试回调(仅可重试失败格触发),参数为 localId。
|
||||||
|
final ValueChanged<int>? onRetry;
|
||||||
|
|
||||||
|
static const _spacing = 4.0;
|
||||||
|
static const _columns = 3;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cellCount = items.length + (canAdd ? 1 : 0);
|
||||||
|
if (cellCount == 0) return const SizedBox.shrink();
|
||||||
|
return GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: _columns,
|
||||||
|
mainAxisSpacing: _spacing,
|
||||||
|
crossAxisSpacing: _spacing,
|
||||||
|
),
|
||||||
|
itemCount: cellCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == items.length) return _AddCell(onTap: onAdd);
|
||||||
|
return _EditCell(
|
||||||
|
item: items[index],
|
||||||
|
onRemove: onRemove == null
|
||||||
|
? null
|
||||||
|
: () => onRemove!(items[index].localId),
|
||||||
|
onRetry: onRetry == null || !items[index].retryable
|
||||||
|
? null
|
||||||
|
: () => onRetry!(items[index].localId),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditCell extends StatelessWidget {
|
||||||
|
const _EditCell({required this.item, this.onRemove, this.onRetry});
|
||||||
|
|
||||||
|
final MediaUploadItem item;
|
||||||
|
final VoidCallback? onRemove;
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
child: Image.memory(
|
||||||
|
item.previewBytes,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
// 选图字节无法解码时的兜底(RemoteImage 同款形态)。
|
||||||
|
errorBuilder: (context, _, _) => const ColoredBox(
|
||||||
|
color: AppColors.surfaceTint,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(Icons.pets, color: AppColors.muted, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
child: UploadProgressOverlay(
|
||||||
|
phase: item.phase,
|
||||||
|
progress: item.progress,
|
||||||
|
onRetry: onRetry,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onRemove,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: const Padding(
|
||||||
|
// 22 圆角标 + padding 撑到 32 触控热区。
|
||||||
|
padding: EdgeInsets.all(5),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Color(0xCC3E2A1F),
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(Icons.close, size: 14, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddCell extends StatelessWidget {
|
||||||
|
const _AddCell({this.onTap});
|
||||||
|
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: const _DashedBorderPainter(),
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.add_photo_alternate_outlined,
|
||||||
|
size: 24,
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「+」格的虚线圆角边框(Flutter 无内置虚线边框,按规范 1.5px 自绘)。
|
||||||
|
class _DashedBorderPainter extends CustomPainter {
|
||||||
|
const _DashedBorderPainter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = AppColors.border
|
||||||
|
..strokeWidth = 1.5
|
||||||
|
..style = PaintingStyle.stroke;
|
||||||
|
final path = Path()
|
||||||
|
..addRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
Offset.zero & size,
|
||||||
|
const Radius.circular(AppRadius.sm),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const dash = 5.0;
|
||||||
|
const gap = 4.0;
|
||||||
|
for (final metric in path.computeMetrics()) {
|
||||||
|
var distance = 0.0;
|
||||||
|
while (distance < metric.length) {
|
||||||
|
final next = distance + dash;
|
||||||
|
canvas.drawPath(
|
||||||
|
metric.extractPath(
|
||||||
|
distance,
|
||||||
|
next > metric.length ? metric.length : next,
|
||||||
|
),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
distance = next + gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_DashedBorderPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单图折叠形态:4:3 圆角封面 + 右下「+N」胶囊角标。
|
||||||
|
class _CollapsedCover extends StatelessWidget {
|
||||||
|
const _CollapsedCover({
|
||||||
|
required this.url,
|
||||||
|
required this.hiddenCount,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
final int hiddenCount;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Widget cover = AspectRatio(
|
||||||
|
aspectRatio: 4 / 3,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
RemoteImage(
|
||||||
|
url: url,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
),
|
||||||
|
if (hiddenCount > 0)
|
||||||
|
Positioned(
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.ink.withAlpha(204),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'+$hiddenCount',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (onTap != null) {
|
||||||
|
cover = InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
child: cover,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
|
||||||
/// M2 记录类型(05 号规范 §2 五种,「其他」为扩展兜底)。
|
/// M2 记录类型(05 号规范 §2 五种,「其他」为扩展兜底;
|
||||||
enum RecordType { weight, vaccine, deworming, medical, other }
|
/// T2-14 为六类健康事件增补 feeding / grooming / measurement 三型,
|
||||||
|
/// 色族复用 05 §2 已审计的四个色对,仅图标与文案区分——对比度结论不变)。
|
||||||
|
enum RecordType {
|
||||||
|
weight,
|
||||||
|
vaccine,
|
||||||
|
deworming,
|
||||||
|
medical,
|
||||||
|
other,
|
||||||
|
feeding,
|
||||||
|
grooming,
|
||||||
|
measurement,
|
||||||
|
}
|
||||||
|
|
||||||
/// 类型 → 图标 + 三色(dot 底 8% 淡染基色 / 图标色 / 文字标签色)+ 文案的
|
/// 类型 → 图标 + 三色(dot 底 8% 淡染基色 / 图标色 / 文字标签色)+ 文案的
|
||||||
/// 唯一映射出口(05 §3.2:映射只存在于本文件一处,杜绝散落硬编码)。
|
/// 唯一映射出口(05 §3.2:映射只存在于本文件一处,杜绝散落硬编码)。
|
||||||
@@ -69,6 +80,28 @@ const Map<RecordType, RecordTypeStyle> recordTypeStyles = {
|
|||||||
inkColor: AppColors.inkSoft,
|
inkColor: AppColors.inkSoft,
|
||||||
label: '其他',
|
label: '其他',
|
||||||
),
|
),
|
||||||
|
// —— T2-14 健康事件增补(色族复用已审计色对)——
|
||||||
|
RecordType.feeding: RecordTypeStyle(
|
||||||
|
icon: Icons.restaurant_outlined,
|
||||||
|
baseColor: AppColors.success,
|
||||||
|
iconColor: AppColors.successInk,
|
||||||
|
inkColor: AppColors.successInk,
|
||||||
|
label: '喂养',
|
||||||
|
),
|
||||||
|
RecordType.grooming: RecordTypeStyle(
|
||||||
|
icon: Icons.content_cut,
|
||||||
|
baseColor: AppColors.accent,
|
||||||
|
iconColor: AppColors.accentDark,
|
||||||
|
inkColor: AppColors.accentDark,
|
||||||
|
label: '洗护',
|
||||||
|
),
|
||||||
|
RecordType.measurement: RecordTypeStyle(
|
||||||
|
icon: Icons.straighten_outlined,
|
||||||
|
baseColor: AppColors.primary,
|
||||||
|
iconColor: AppColors.primaryStrong,
|
||||||
|
inkColor: AppColors.primaryDark,
|
||||||
|
label: '测量',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 圆标尺寸档(05 §3.2)。图标尺寸 = dot 的 50%。
|
/// 圆标尺寸档(05 §3.2)。图标尺寸 = dot 的 50%。
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
|
||||||
|
/// 上传进度覆盖层(设计规范 05 号 §3.3):叠加在编辑态九宫格单格上,
|
||||||
|
/// 四视觉态映射 [MediaItemPhase]:
|
||||||
|
///
|
||||||
|
/// | 视觉态 | 阶段 | 形态 |
|
||||||
|
/// | --- | --- | --- |
|
||||||
|
/// | 排队 | queued / compressing | ink 40% scrim + 「等待中」白字胶囊 |
|
||||||
|
/// | 上传中 | uploading / confirming | scrim + 白色环形进度 36 + 百分比胶囊 |
|
||||||
|
/// | 成功 | ready | scrim 150ms 淡出,无残留角标 |
|
||||||
|
/// | 失败 | failed | error 12% scrim + errorDark 图标 + 底部「重试」通栏 |
|
||||||
|
///
|
||||||
|
/// 失败态整格点按重试([onRetry],仅可重试失败传入);组件本身不含
|
||||||
|
/// 图片,由九宫格把它叠在缩略图上(Stack)。
|
||||||
|
class UploadProgressOverlay extends StatelessWidget {
|
||||||
|
const UploadProgressOverlay({
|
||||||
|
required this.phase,
|
||||||
|
this.progress = 0,
|
||||||
|
this.onRetry,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MediaItemPhase phase;
|
||||||
|
|
||||||
|
/// 直传进度 0..1(uploading 态显示;confirming 定格 100%)。
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
/// 失败态整格点按回调;null 即失败不可重试(终态,只展示不响应)。
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return switch (phase) {
|
||||||
|
MediaItemPhase.queued || MediaItemPhase.compressing => _Scrim(
|
||||||
|
child: Center(child: _pill(const Text('等待中', style: _pillTextSm))),
|
||||||
|
),
|
||||||
|
MediaItemPhase.uploading || MediaItemPhase.confirming => _Scrim(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: phase == MediaItemPhase.confirming ? 1.0 : progress,
|
||||||
|
color: Colors.white,
|
||||||
|
backgroundColor: Colors.white24,
|
||||||
|
strokeWidth: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_pill(
|
||||||
|
Text(
|
||||||
|
'${((phase == MediaItemPhase.confirming ? 1.0 : progress) * 100).round()}%',
|
||||||
|
style: _pillTextXs,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 成功:scrim 150ms 淡出后不留任何角标。
|
||||||
|
MediaItemPhase.ready => const IgnorePointer(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: 0,
|
||||||
|
duration: Duration(milliseconds: 150),
|
||||||
|
child: _Scrim(child: SizedBox.expand()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MediaItemPhase.failed => GestureDetector(
|
||||||
|
onTap: onRetry,
|
||||||
|
child: Container(
|
||||||
|
color: AppColors.error.withAlpha(31),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 24,
|
||||||
|
color: AppColors.errorDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onRetry != null)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: AppColors.errorDark,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: const Text(
|
||||||
|
'重试',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _pillTextSm = TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const _pillTextXs = TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 白字衬 ink 80% 胶囊底(对 40% scrim 上的合成底色对比度兜底,§3.3)。
|
||||||
|
Widget _pill(Widget child) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.ink.withAlpha(204),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(AppRadius.pill)),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ink 40% 全格 scrim。
|
||||||
|
class _Scrim extends StatelessWidget {
|
||||||
|
const _Scrim({required this.child});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(color: AppColors.ink.withAlpha(102), child: child);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/toggle_sync.dart';
|
||||||
|
|
||||||
|
/// Feed 首屏四态(同 pets 先例)。
|
||||||
|
enum FeedPhase { initial, loading, ready, error }
|
||||||
|
|
||||||
|
/// 尾部加载更多三态(游标累积流新增,pets 无此并发点)。
|
||||||
|
enum LoadMorePhase { idle, loading, error }
|
||||||
|
|
||||||
|
/// community feature 状态控制器(03 号评估 §2 分层:
|
||||||
|
/// Page/Widget → CommunityController → CommunityRepository → ApiClient)。
|
||||||
|
///
|
||||||
|
/// Tab 级单例(app.dart 装配注入主壳):Feed 是游标累积流,且详情页与
|
||||||
|
/// 首页共享同一份帖子内存副本(点赞状态跨页一致),不做页面级 state。
|
||||||
|
/// 评论列表只属详情页,按「页面级状态按页自建」纪律经 [repository]
|
||||||
|
/// 自取,不膨胀本控制器。服务端是唯一事实来源,内存副本仅作展示缓存。
|
||||||
|
class CommunityController extends ChangeNotifier {
|
||||||
|
CommunityController({required this._repository, this._interactionAnalytics}) {
|
||||||
|
_likeSync = ToggleSync(
|
||||||
|
read: (id) {
|
||||||
|
final post = _postCache[id];
|
||||||
|
if (post != null) {
|
||||||
|
return ToggleReading(active: post.likedByMe, count: post.likeCount);
|
||||||
|
}
|
||||||
|
final card = _cardOrNull(id);
|
||||||
|
if (card == null) return null;
|
||||||
|
return ToggleReading(active: card.likedByMe, count: card.likeCount);
|
||||||
|
},
|
||||||
|
write: (id, active, count) =>
|
||||||
|
_writeInteraction(id, likedByMe: active, likeCount: count),
|
||||||
|
send: (id, target) async {
|
||||||
|
final state = target
|
||||||
|
? await _repository.likePost(id)
|
||||||
|
: await _repository.unlikePost(id);
|
||||||
|
// 成功响应后上报(06 §1.4 口径;乐观翻转与失败均不报)。
|
||||||
|
final source = _likeSources[id] ?? InteractionSource.feed;
|
||||||
|
target
|
||||||
|
? _interactionAnalytics?.postLiked(source: source)
|
||||||
|
: _interactionAnalytics?.postUnliked(source: source);
|
||||||
|
return ToggleOutcome(active: state.liked, count: state.likeCount);
|
||||||
|
},
|
||||||
|
generation: () => _generation,
|
||||||
|
onError: _onToggleError,
|
||||||
|
);
|
||||||
|
_bookmarkSync = ToggleSync(
|
||||||
|
read: (id) {
|
||||||
|
final post = _postCache[id];
|
||||||
|
if (post != null) {
|
||||||
|
return ToggleReading(
|
||||||
|
active: post.bookmarkedByMe,
|
||||||
|
count: post.bookmarkCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final card = _cardOrNull(id);
|
||||||
|
if (card == null) return null;
|
||||||
|
return ToggleReading(
|
||||||
|
active: card.bookmarkedByMe,
|
||||||
|
count: card.bookmarkCount,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
write: (id, active, count) =>
|
||||||
|
_writeInteraction(id, bookmarkedByMe: active, bookmarkCount: count),
|
||||||
|
send: (id, target) async {
|
||||||
|
final state = target
|
||||||
|
? await _repository.bookmarkPost(id)
|
||||||
|
: await _repository.unbookmarkPost(id);
|
||||||
|
final source = _bookmarkSources[id] ?? InteractionSource.feed;
|
||||||
|
target
|
||||||
|
? _interactionAnalytics?.postFavorited(source: source)
|
||||||
|
: _interactionAnalytics?.postUnfavorited(source: source);
|
||||||
|
return ToggleOutcome(
|
||||||
|
active: state.bookmarked,
|
||||||
|
count: state.bookmarkCount,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
generation: () => _generation,
|
||||||
|
onError: _onToggleError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final CommunityRepository _repository;
|
||||||
|
final CommunityInteractionAnalytics? _interactionAnalytics;
|
||||||
|
|
||||||
|
/// 各帖最近一次 toggle 的触点来源(Feed 卡片 / 详情页共享同一实例,
|
||||||
|
/// 成功响应上报时按发起触点归因)。
|
||||||
|
final Map<String, InteractionSource> _likeSources = {};
|
||||||
|
final Map<String, InteractionSource> _bookmarkSources = {};
|
||||||
|
|
||||||
|
/// 页面级状态(评论列表、我的帖子、收藏页等)按页直接经仓库取数。
|
||||||
|
CommunityRepository get repository => _repository;
|
||||||
|
|
||||||
|
late final ToggleSync _likeSync;
|
||||||
|
late final ToggleSync _bookmarkSync;
|
||||||
|
|
||||||
|
FeedPhase _phase = FeedPhase.initial;
|
||||||
|
LoadMorePhase _loadMorePhase = LoadMorePhase.idle;
|
||||||
|
List<FeedCard> _feed = const [];
|
||||||
|
String? _nextCursor;
|
||||||
|
bool _hasMore = false;
|
||||||
|
|
||||||
|
/// 首屏加载失败(error 态时非 null)。
|
||||||
|
ApiException? _lastError;
|
||||||
|
|
||||||
|
/// 刷新失败但旧列表被保留时的错误(页面 SnackBar 轻提示后消费)。
|
||||||
|
ApiException? _refreshError;
|
||||||
|
|
||||||
|
/// 加载更多失败(尾部重试条渲染依据)。
|
||||||
|
ApiException? _loadMoreError;
|
||||||
|
|
||||||
|
/// 最近一次点赞/收藏对账失败(T3-15/16 SnackBar 消费)。
|
||||||
|
ApiException? _toggleError;
|
||||||
|
|
||||||
|
/// 刷新代次:整体替换列表后,在途旧代次响应(尾页 / 互动对账)一律丢弃。
|
||||||
|
int _generation = 0;
|
||||||
|
|
||||||
|
/// 详情内存副本(详情页与 Feed 卡片互动状态同源)。
|
||||||
|
final Map<String, Post> _postCache = {};
|
||||||
|
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
FeedPhase get phase => _phase;
|
||||||
|
LoadMorePhase get loadMorePhase => _loadMorePhase;
|
||||||
|
|
||||||
|
/// 累积的多页 Feed 缓存(服务端 published_at DESC, id DESC 原样保留)。
|
||||||
|
List<FeedCard> get feed => _feed;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
|
||||||
|
/// ready 且列表为空 → 空态。
|
||||||
|
bool get isEmpty => _phase == FeedPhase.ready && _feed.isEmpty;
|
||||||
|
|
||||||
|
ApiException? get lastError => _lastError;
|
||||||
|
ApiException? get refreshError => _refreshError;
|
||||||
|
ApiException? get loadMoreError => _loadMoreError;
|
||||||
|
ApiException? get toggleError => _toggleError;
|
||||||
|
|
||||||
|
/// 详情内存副本(进入详情页先渲染缓存,再 [getPost] 拉新)。
|
||||||
|
Post? cachedPost(String postId) => _postCache[postId];
|
||||||
|
|
||||||
|
/// 首屏加载 / 下拉刷新:丢弃游标从头拉第一页,成功后**整体替换**累积列表。
|
||||||
|
/// 刷新失败保留旧列表(不清空不闪空态),错误经 [refreshError] 轻提示;
|
||||||
|
/// 首屏(空列表)失败收敛为 error 态供页面渲染 + retry。
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_generation += 1;
|
||||||
|
final generation = _generation;
|
||||||
|
_lastError = null;
|
||||||
|
_refreshError = null;
|
||||||
|
_loadMorePhase = LoadMorePhase.idle;
|
||||||
|
_loadMoreError = null;
|
||||||
|
if (_feed.isEmpty) _phase = FeedPhase.loading;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _repository.getFeed();
|
||||||
|
if (generation != _generation) return; // 期间又发生过刷新/登出。
|
||||||
|
_feed = page.items;
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_phase = FeedPhase.ready;
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (generation != _generation) return;
|
||||||
|
if (_feed.isEmpty) {
|
||||||
|
_lastError = error;
|
||||||
|
_phase = FeedPhase.error;
|
||||||
|
} else {
|
||||||
|
_refreshError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 滚动近底加载下一页:携带上一页 nextCursor(keyset 翻页不丢不重)。
|
||||||
|
/// 失败置 [LoadMorePhase.error] 渲染尾部重试条;期间发生过刷新的
|
||||||
|
/// 旧代次响应直接丢弃(避免「刷新后旧尾页追加」的重复/错位)。
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (_phase != FeedPhase.ready ||
|
||||||
|
!_hasMore ||
|
||||||
|
_loadMorePhase == LoadMorePhase.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final generation = _generation;
|
||||||
|
_loadMorePhase = LoadMorePhase.loading;
|
||||||
|
_loadMoreError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _repository.getFeed(cursor: _nextCursor);
|
||||||
|
if (generation != _generation) return; // 旧代次尾页,丢弃。
|
||||||
|
_feed = [..._feed, ...page.items];
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_loadMorePhase = LoadMorePhase.idle;
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (generation != _generation) return;
|
||||||
|
_loadMoreError = error;
|
||||||
|
_loadMorePhase = LoadMorePhase.error;
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取帖子详情并同步内存副本(Feed 卡片互动字段一并回写)。
|
||||||
|
Future<Post> getPost(String postId) async {
|
||||||
|
final post = await _repository.getPost(postId);
|
||||||
|
_postCache[postId] = post;
|
||||||
|
_syncCardFromPost(post);
|
||||||
|
_notify();
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞/取消点赞(乐观翻转,终态由 [ToggleSync] 对账收敛,不外抛)。
|
||||||
|
/// [source] 为触点来源(埋点归因),Feed 卡片缺省 feed、详情页传
|
||||||
|
/// post_detail。
|
||||||
|
void toggleLike(
|
||||||
|
String postId, {
|
||||||
|
InteractionSource source = InteractionSource.feed,
|
||||||
|
}) {
|
||||||
|
_likeSources[postId] = source;
|
||||||
|
_likeSync.toggle(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏/取消收藏(与点赞同构)。
|
||||||
|
void toggleBookmark(
|
||||||
|
String postId, {
|
||||||
|
InteractionSource source = InteractionSource.feed,
|
||||||
|
}) {
|
||||||
|
_bookmarkSources[postId] = source;
|
||||||
|
_bookmarkSync.toggle(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论创建/删除后的计数调整(详情页与 Feed 卡片同源写入,
|
||||||
|
/// 服务端 comment_count 由写侧同事务维护,本地只做展示对齐)。
|
||||||
|
void adjustCommentCount(String postId, int delta) {
|
||||||
|
final current =
|
||||||
|
_postCache[postId]?.commentCount ?? _cardOrNull(postId)?.commentCount;
|
||||||
|
if (current == null) return;
|
||||||
|
final next = current + delta;
|
||||||
|
_writeInteraction(postId, commentCount: next < 0 ? 0 : next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 消费一次性 toggle 错误(SnackBar 展示后清除)。
|
||||||
|
void clearToggleError() => _toggleError = null;
|
||||||
|
|
||||||
|
/// 登出清空:回 initial 态、清多页缓存与详情副本、丢弃全部在途链,
|
||||||
|
/// 避免上一账号数据跨会话泄漏;重登后主壳重建,Feed 页重新触发 [refresh]。
|
||||||
|
void reset() {
|
||||||
|
_generation += 1;
|
||||||
|
_phase = FeedPhase.initial;
|
||||||
|
_loadMorePhase = LoadMorePhase.idle;
|
||||||
|
_feed = const [];
|
||||||
|
_nextCursor = null;
|
||||||
|
_hasMore = false;
|
||||||
|
_lastError = null;
|
||||||
|
_refreshError = null;
|
||||||
|
_loadMoreError = null;
|
||||||
|
_toggleError = null;
|
||||||
|
_postCache.clear();
|
||||||
|
_likeSources.clear();
|
||||||
|
_bookmarkSources.clear();
|
||||||
|
_likeSync.reset();
|
||||||
|
_bookmarkSync.reset();
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
FeedCard? _cardOrNull(String postId) {
|
||||||
|
for (final card in _feed) {
|
||||||
|
if (card.id == postId) return card;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 互动字段统一写入口:详情副本与 Feed 卡片同步更新后 notify(同帧反馈)。
|
||||||
|
void _writeInteraction(
|
||||||
|
String postId, {
|
||||||
|
bool? likedByMe,
|
||||||
|
int? likeCount,
|
||||||
|
bool? bookmarkedByMe,
|
||||||
|
int? bookmarkCount,
|
||||||
|
int? commentCount,
|
||||||
|
}) {
|
||||||
|
final post = _postCache[postId];
|
||||||
|
if (post != null) {
|
||||||
|
_postCache[postId] = post.copyWithInteraction(
|
||||||
|
likedByMe: likedByMe,
|
||||||
|
likeCount: likeCount,
|
||||||
|
bookmarkedByMe: bookmarkedByMe,
|
||||||
|
bookmarkCount: bookmarkCount,
|
||||||
|
commentCount: commentCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final index = _feed.indexWhere((card) => card.id == postId);
|
||||||
|
if (index != -1) {
|
||||||
|
_feed = [..._feed]
|
||||||
|
..[index] = _feed[index].copyWithInteraction(
|
||||||
|
likedByMe: likedByMe,
|
||||||
|
likeCount: likeCount,
|
||||||
|
bookmarkedByMe: bookmarkedByMe,
|
||||||
|
bookmarkCount: bookmarkCount,
|
||||||
|
commentCount: commentCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (post != null || index != -1) _notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncCardFromPost(Post post) {
|
||||||
|
final index = _feed.indexWhere((card) => card.id == post.id);
|
||||||
|
if (index == -1) return;
|
||||||
|
_feed = [..._feed]
|
||||||
|
..[index] = _feed[index].copyWithInteraction(
|
||||||
|
likedByMe: post.likedByMe,
|
||||||
|
likeCount: post.likeCount,
|
||||||
|
commentCount: post.commentCount,
|
||||||
|
bookmarkedByMe: post.bookmarkedByMe,
|
||||||
|
bookmarkCount: post.bookmarkCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onToggleError(String postId, Object error) {
|
||||||
|
if (error is ApiException) {
|
||||||
|
_toggleError = error;
|
||||||
|
} else {
|
||||||
|
_toggleError = ApiNetworkException('$error');
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
|
||||||
|
/// 降级作者([AuthorSummary.isDegraded],资料暂不可得或已注销)的
|
||||||
|
/// 统一占位名(05 号规范:占位头像 + 默认名,客户端不做昵称回退拼装)。
|
||||||
|
const degradedAuthorName = '宠友';
|
||||||
|
|
||||||
|
/// 作者展示名:正常路径 nickname 恒非空(服务端已回退 username);
|
||||||
|
/// 降级形态统一 [degradedAuthorName]。
|
||||||
|
String authorDisplayName(AuthorSummary author) =>
|
||||||
|
author.nickname ?? degradedAuthorName;
|
||||||
|
|
||||||
|
/// Feed 卡片元信息的相对时间(正典「2 小时前」语言)。
|
||||||
|
/// [now] 注入口供测试锁定时钟。
|
||||||
|
String feedRelativeTime(DateTime time, {DateTime? now}) {
|
||||||
|
final reference = now ?? DateTime.now();
|
||||||
|
final difference = reference.difference(time.toLocal());
|
||||||
|
if (difference.inMinutes < 1) return '刚刚';
|
||||||
|
if (difference.inHours < 1) return '${difference.inMinutes} 分钟前';
|
||||||
|
if (difference.inDays < 1) return '${difference.inHours} 小时前';
|
||||||
|
if (difference.inDays < 7) return '${difference.inDays} 天前';
|
||||||
|
final local = time.toLocal();
|
||||||
|
if (local.year == reference.year) return '${local.month}月${local.day}日';
|
||||||
|
return '${local.year}年${local.month}月${local.day}日';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed 加载失败的用户话术(pets 域 petLoadErrorMessage 同构;
|
||||||
|
/// 服务端原始 message 不上屏)。
|
||||||
|
String feedLoadErrorMessage(ApiException? error) => switch (error) {
|
||||||
|
ApiNetworkException _ => '网络异常,请检查网络后重试',
|
||||||
|
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
|
||||||
|
_ => '动态加载失败,请稍后重试',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 发布失败的用户话术(T3-17,三条关键语义各自可辨;服务端原始
|
||||||
|
/// message 不上屏):
|
||||||
|
///
|
||||||
|
/// - 40905 同键异 payload:提交标识已被页面重置,再点一次即可;
|
||||||
|
/// - 42203 asset 未 ready:等图片传完再发;
|
||||||
|
/// - 网络失败:可重试(同键重放,服务端不会重复建帖)。
|
||||||
|
String postPublishErrorMessage(ApiException? error) => switch (error) {
|
||||||
|
IdempotencyMismatchException _ => '提交内容与上次重试不一致,已重置提交标识,请再点一次「发布」',
|
||||||
|
MediaNotReadyException _ => '有图片还没上传完成,请等图片就绪后再发布',
|
||||||
|
PostNotFoundException _ => '草稿已不存在(可能已在别处删除),请重新发布',
|
||||||
|
PostVersionConflictException _ => '草稿在别处被修改过,请重试发布',
|
||||||
|
ApiNetworkException _ => '网络异常,请检查网络后重试',
|
||||||
|
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
|
||||||
|
ApiBusinessException(:final code) when code == ApiCodes.paramError =>
|
||||||
|
'内容不符合发布要求,请修改后重试',
|
||||||
|
_ => '发布失败,请稍后重试',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 草稿保存失败的用户话术(发布页 SnackBar)。
|
||||||
|
String draftSaveErrorMessage(ApiException? error) => switch (error) {
|
||||||
|
ApiNetworkException _ => '网络异常,草稿未保存,请重试',
|
||||||
|
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
|
||||||
|
_ => '草稿保存失败,请重试',
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
|
||||||
|
/// community / media 域类型化业务异常(契约 v1.3.0 定型的 9 个新错误码,
|
||||||
|
/// 20 号收口报告 §1;40902 乐观锁与 pets 域共码,本域映射为独立类型)。
|
||||||
|
/// 全部继承 [ApiBusinessException],既有按基类捕获的通用错误处理不受影响。
|
||||||
|
|
||||||
|
/// 40301:对可见帖子/评论无相应操作权限(改删他人已发布帖、
|
||||||
|
/// 删他人可见评论——含帖主删他人评论,D3-7 首版不做)。
|
||||||
|
final class PostAccessDeniedException extends ApiBusinessException {
|
||||||
|
const PostAccessDeniedException({required super.message})
|
||||||
|
: super(code: ApiCodes.postAccessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40403:帖子不存在 / 已软删 / hidden/archived(含作者)/ 他人 draft
|
||||||
|
/// (防枚举,全部情况响应完全一致;互动路径上含作者本人草稿)。
|
||||||
|
final class PostNotFoundException extends ApiBusinessException {
|
||||||
|
const PostNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.postNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40404:评论不存在、已删或所属帖子不可见(防枚举合并)。
|
||||||
|
final class CommentNotFoundException extends ApiBusinessException {
|
||||||
|
const CommentNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.commentNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40405:media asset 不存在、非本人所有或已删(防枚举合并)。
|
||||||
|
final class MediaAssetNotFoundException extends ApiBusinessException {
|
||||||
|
const MediaAssetNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.mediaNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40406:目标用户不存在或已注销(合并不泄露成因)。
|
||||||
|
final class CommunityUserNotFoundException extends ApiBusinessException {
|
||||||
|
const CommunityUserNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.communityUserNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40902:帖子编辑乐观锁版本冲突(version 过期)。
|
||||||
|
/// 客户端处理:刷新详情取新 version 后重提。
|
||||||
|
final class PostVersionConflictException extends ApiBusinessException {
|
||||||
|
const PostVersionConflictException({required super.message})
|
||||||
|
: super(code: ApiCodes.versionConflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40905:同 Idempotency-Key 不同 payload(规范化 request_hash 不符)。
|
||||||
|
/// 客户端每次逻辑提交应换新键,重试间保持不变。
|
||||||
|
final class IdempotencyMismatchException extends ApiBusinessException {
|
||||||
|
const IdempotencyMismatchException({required super.message})
|
||||||
|
: super(code: ApiCodes.idempotencyKeyMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42203:引用了本人所有但非 ready(uploading/failed)状态的 asset。
|
||||||
|
final class MediaNotReadyException extends ApiBusinessException {
|
||||||
|
const MediaNotReadyException({required super.message})
|
||||||
|
: super(code: ApiCodes.mediaNotReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42204:自关注(仅 PUT;自取关走 DELETE 的 200 幂等 no-op)。
|
||||||
|
final class SelfFollowException extends ApiBusinessException {
|
||||||
|
const SelfFollowException({required super.message})
|
||||||
|
: super(code: ApiCodes.selfFollow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42205:上传状态不允许确认——对象未上传(保持 uploading 可重试)、
|
||||||
|
/// 大小/类型不符(置 failed 终态)、failed 态再确认(已 ready 幂等 200 除外)。
|
||||||
|
final class MediaUploadStateException extends ApiBusinessException {
|
||||||
|
const MediaUploadStateException({required super.message})
|
||||||
|
: super(code: ApiCodes.mediaUploadStateInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把通用业务异常按 community/media 域错误码升格为类型化异常;
|
||||||
|
/// 未覆盖的码(40000 参数错误、40401 宠物防枚举等)原样返回,沿用通用处理。
|
||||||
|
ApiBusinessException mapCommunityBusinessException(ApiBusinessException error) {
|
||||||
|
return switch (error.code) {
|
||||||
|
ApiCodes.postAccessDenied => PostAccessDeniedException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.postNotFound => PostNotFoundException(message: error.message),
|
||||||
|
ApiCodes.commentNotFound => CommentNotFoundException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.mediaNotFound => MediaAssetNotFoundException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.communityUserNotFound => CommunityUserNotFoundException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.versionConflict => PostVersionConflictException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.idempotencyKeyMismatch => IdempotencyMismatchException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.mediaNotReady => MediaNotReadyException(message: error.message),
|
||||||
|
ApiCodes.selfFollow => SelfFollowException(message: error.message),
|
||||||
|
ApiCodes.mediaUploadStateInvalid => MediaUploadStateException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
_ => error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
|
||||||
|
/// 互动域埋点强类型封装(06 号规划 §1.4 字典 v3 互动八事件;后端白名单
|
||||||
|
/// 已随 api dev@8089c06 就绪,22 号报告 §1)。沿 pet/feed 域惯例:枚举
|
||||||
|
/// 编译期锁死,业务代码禁止手拼事件名与属性;只记行为不记内容
|
||||||
|
/// (隐私红线:postId/commentId 等内容 ID 一律不进 props)。
|
||||||
|
///
|
||||||
|
/// 点赞/收藏/关注**不埋失败**(06 §1.4 取舍:幂等写入单点交互,失败率
|
||||||
|
/// 靠服务端错误率观测);`comment_create_started` 被字典锁死 unknown,
|
||||||
|
/// 不得上报(22 号 §1 末段)。
|
||||||
|
|
||||||
|
/// 互动触点来源(06 §1.4 source 枚举)。M3 接 feed / post_detail;
|
||||||
|
/// user_profile / follow_list 随后续页面启用。
|
||||||
|
enum InteractionSource {
|
||||||
|
feed('feed'),
|
||||||
|
postDetail('post_detail'),
|
||||||
|
userProfile('user_profile'),
|
||||||
|
followList('follow_list');
|
||||||
|
|
||||||
|
const InteractionSource(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论创建失败原因(06 §1.4 失败枚举基底)。网络归并口径同 pet/feed 域:
|
||||||
|
/// 断网/超时/5xx 均并入 network_error,server_error 保留兜底。
|
||||||
|
enum CommentCreateFailureReason {
|
||||||
|
validationError('validation_error'),
|
||||||
|
notFound('not_found'),
|
||||||
|
rateLimited('rate_limited'),
|
||||||
|
networkError('network_error'),
|
||||||
|
serverError('server_error');
|
||||||
|
|
||||||
|
const CommentCreateFailureReason(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 类型化异常 → 评论失败原因;会话失效返回 null(应用即将回登录页,
|
||||||
|
/// 不作为评论失败上报,feed 域同款口径)。
|
||||||
|
CommentCreateFailureReason? commentCreateFailureReasonOf(ApiException error) =>
|
||||||
|
switch (error) {
|
||||||
|
ApiNetworkException _ => CommentCreateFailureReason.networkError,
|
||||||
|
ApiRateLimitException _ => CommentCreateFailureReason.rateLimited,
|
||||||
|
SessionExpiredException _ => null,
|
||||||
|
ApiBusinessException(:final code) => switch (code) {
|
||||||
|
ApiCodes.paramError => CommentCreateFailureReason.validationError,
|
||||||
|
ApiCodes.postNotFound ||
|
||||||
|
ApiCodes.commentNotFound ||
|
||||||
|
ApiCodes.communityUserNotFound => CommentCreateFailureReason.notFound,
|
||||||
|
_ => CommentCreateFailureReason.serverError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 正文规模分桶(06 §1.3 隐私红线 1:不报精确字数)。
|
||||||
|
/// empty / short(≤50) / medium(51–500) / long(>500)。
|
||||||
|
String textLengthBucketOf(int length) {
|
||||||
|
if (length <= 0) return 'empty';
|
||||||
|
if (length <= 50) return 'short';
|
||||||
|
if (length <= 500) return 'medium';
|
||||||
|
return 'long';
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommunityInteractionAnalytics {
|
||||||
|
CommunityInteractionAnalytics(this._track);
|
||||||
|
|
||||||
|
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
|
||||||
|
final TrackEventFn _track;
|
||||||
|
|
||||||
|
/// 点赞成功响应后(乐观翻转本身不报;单飞合并链每个实际抵达服务端
|
||||||
|
/// 并成功的状态变更各报一条,与「成功响应后」的字典口径一致)。
|
||||||
|
void postLiked({required InteractionSource source}) {
|
||||||
|
_track('post_liked', {'source': source.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消点赞成功响应后。
|
||||||
|
void postUnliked({required InteractionSource source}) {
|
||||||
|
_track('post_unliked', {'source': source.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏成功响应后。
|
||||||
|
void postFavorited({required InteractionSource source}) {
|
||||||
|
_track('post_favorited', {'source': source.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消收藏成功响应后。
|
||||||
|
void postUnfavorited({required InteractionSource source}) {
|
||||||
|
_track('post_unfavorited', {'source': source.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论提交成功响应后。
|
||||||
|
///
|
||||||
|
/// [durationMs]:本次输入会话(首个字符输入)→ 成功响应的耗时
|
||||||
|
/// (评论不设 started 事件,时长随成功事件带出);
|
||||||
|
/// [textLength] 经 [textLengthBucketOf] 分桶后上报,精确字数不出端。
|
||||||
|
void commentCreateSucceeded({
|
||||||
|
required int durationMs,
|
||||||
|
required bool isReply,
|
||||||
|
required int textLength,
|
||||||
|
}) {
|
||||||
|
_track('comment_create_succeeded', {
|
||||||
|
'durationMs': durationMs,
|
||||||
|
'isReply': isReply,
|
||||||
|
'textLengthBucket': textLengthBucketOf(textLength),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论提交失败。
|
||||||
|
///
|
||||||
|
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位
|
||||||
|
/// 业务码推导(`code ~/ 100`,pet 域同款口径);[attemptSeq] 为本次
|
||||||
|
/// 输入会话内第几次提交尝试(从 1 起,成功或清空输入后重置)。
|
||||||
|
void commentCreateFailed({
|
||||||
|
required CommentCreateFailureReason reason,
|
||||||
|
required int attemptSeq,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('comment_create_failed', {
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'attemptSeq': attemptSeq,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关注成功响应后。
|
||||||
|
void userFollowed({required InteractionSource source}) {
|
||||||
|
_track('user_followed', {'source': source.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取关成功响应后。
|
||||||
|
void userUnfollowed({required InteractionSource source}) {
|
||||||
|
_track('user_unfollowed', {'source': source.value});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
/// community / media 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.3.0,
|
||||||
|
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
|
||||||
|
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'package:patbond_flutter/core/models/cursor_page.dart';
|
||||||
|
|
||||||
|
T _enumFromJson<T extends Enum>(List<T> values, String raw, String field) {
|
||||||
|
for (final value in values) {
|
||||||
|
if (value.name == raw) return value;
|
||||||
|
}
|
||||||
|
throw FormatException('未知的 $field 取值:$raw');
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _dateTimeOrNull(Object? value) =>
|
||||||
|
value == null ? null : DateTime.parse(value as String);
|
||||||
|
|
||||||
|
/// 帖子分类。ai_creation 为 M4 预留值,仅读侧出现(M3 提交即 400/40000)。
|
||||||
|
enum PostCategory {
|
||||||
|
general('general'),
|
||||||
|
help('help'),
|
||||||
|
aiCreation('ai_creation');
|
||||||
|
|
||||||
|
const PostCategory(this.wire);
|
||||||
|
|
||||||
|
/// 契约线上取值(aiCreation 的枚举名与线上 snake_case 不同,序列化走本值)。
|
||||||
|
final String wire;
|
||||||
|
|
||||||
|
static PostCategory fromJson(String value) {
|
||||||
|
for (final category in values) {
|
||||||
|
if (category.wire == value) return category;
|
||||||
|
}
|
||||||
|
throw FormatException('未知的 category 取值:$value');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子状态。hidden/archived(运营态)永不出现在响应(对作者与他人一律
|
||||||
|
/// 404/40403),枚举保持两值。
|
||||||
|
enum PostStatus {
|
||||||
|
draft,
|
||||||
|
published;
|
||||||
|
|
||||||
|
static PostStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子可见性。M3 恒 public(followers/private 语义后置,字段保留)。
|
||||||
|
enum PostVisibility {
|
||||||
|
public;
|
||||||
|
|
||||||
|
static PostVisibility fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'visibility');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// media asset 类型。M3 仅 image(视频后置,video/document 为向后新增预留)。
|
||||||
|
enum MediaKind {
|
||||||
|
image;
|
||||||
|
|
||||||
|
static MediaKind fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'kind');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传用途白名单(M3 定型仅 post_image,决定 objectKey 前缀)。
|
||||||
|
enum MediaPurpose {
|
||||||
|
postImage('post_image');
|
||||||
|
|
||||||
|
const MediaPurpose(this.wire);
|
||||||
|
|
||||||
|
final String wire;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// media asset 状态。deleted 态对外恒 404/40405,不出现在响应。
|
||||||
|
enum MediaAssetStatus {
|
||||||
|
uploading,
|
||||||
|
ready,
|
||||||
|
failed;
|
||||||
|
|
||||||
|
static MediaAssetStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 作者公开摘要(D3-9 方案 B)。正常路径 nickname 恒非空(空昵称由服务端
|
||||||
|
/// 回退为 username,客户端不做回退拼装);nickname 与 avatarUrl 同为 null
|
||||||
|
/// 即「降级/墓碑」形态(作者资料暂不可得或已注销),客户端只需一种占位逻辑。
|
||||||
|
class AuthorSummary {
|
||||||
|
const AuthorSummary({
|
||||||
|
required this.userId,
|
||||||
|
required this.nickname,
|
||||||
|
required this.avatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AuthorSummary.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AuthorSummary(
|
||||||
|
userId: json['userId'] as String,
|
||||||
|
nickname: json['nickname'] as String?,
|
||||||
|
// 时效性预签名 GET URL,每次响应现签,不得持久化、过期即重取。
|
||||||
|
avatarUrl: json['avatarUrl'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final String? nickname;
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
|
/// 降级/墓碑形态(id-only):页面渲染统一占位。
|
||||||
|
bool get isDegraded => nickname == null && avatarUrl == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子挂接的一张图(响应形态)。url 为时效性预签名 GET(TTL 默认 1 小时),
|
||||||
|
/// 每次响应现签,客户端不得持久化、过期即重取。
|
||||||
|
class PostMediaItem {
|
||||||
|
const PostMediaItem({
|
||||||
|
required this.assetId,
|
||||||
|
required this.position,
|
||||||
|
required this.isCover,
|
||||||
|
required this.url,
|
||||||
|
required this.widthPx,
|
||||||
|
required this.heightPx,
|
||||||
|
required this.caption,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PostMediaItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PostMediaItem(
|
||||||
|
assetId: json['assetId'] as String,
|
||||||
|
position: json['position'] as int,
|
||||||
|
isCover: json['isCover'] as bool,
|
||||||
|
url: json['url'] as String,
|
||||||
|
widthPx: json['widthPx'] as int?,
|
||||||
|
heightPx: json['heightPx'] as int?,
|
||||||
|
caption: json['caption'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String assetId;
|
||||||
|
final int position;
|
||||||
|
final bool isCover;
|
||||||
|
final String url;
|
||||||
|
final int? widthPx;
|
||||||
|
final int? heightPx;
|
||||||
|
final String? caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子挂接的一张图(请求形态)。position 全给或全不给(全给须恰为 0..n-1
|
||||||
|
/// 连续不重复,混合 400/40000);isCover 至多一个 true;caption trim 后 ≤300。
|
||||||
|
class PostMediaAttachRequest {
|
||||||
|
const PostMediaAttachRequest({
|
||||||
|
required this.assetId,
|
||||||
|
this.position,
|
||||||
|
this.isCover,
|
||||||
|
this.caption,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String assetId;
|
||||||
|
final int? position;
|
||||||
|
final bool? isCover;
|
||||||
|
final String? caption;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'assetId': assetId,
|
||||||
|
if (position != null) 'position': position,
|
||||||
|
if (isCover != null) 'isCover': isCover,
|
||||||
|
if (caption != null) 'caption': caption,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建帖子请求。content 必填(纯文字帖合法,media 空数组或缺席);
|
||||||
|
/// status=published 即创建即发布(服务端写 publishedAt)。
|
||||||
|
class CreatePostRequest {
|
||||||
|
const CreatePostRequest({
|
||||||
|
required this.content,
|
||||||
|
this.title,
|
||||||
|
this.category,
|
||||||
|
this.status,
|
||||||
|
this.petId,
|
||||||
|
this.media,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String content;
|
||||||
|
final String? title;
|
||||||
|
final PostCategory? category;
|
||||||
|
final PostStatus? status;
|
||||||
|
final String? petId;
|
||||||
|
final List<PostMediaAttachRequest>? media;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'content': content,
|
||||||
|
if (title != null) 'title': title,
|
||||||
|
if (category != null) 'category': category!.wire,
|
||||||
|
if (status != null) 'status': status!.name,
|
||||||
|
if (petId != null) 'petId': petId,
|
||||||
|
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑帖子 / 发布草稿请求(部分更新:缺席字段不变,不支持清空回 null;
|
||||||
|
/// version 乐观锁必带)。
|
||||||
|
///
|
||||||
|
/// - [publish]:`status: published` 状态迁移(draft→published 唯一开放迁移;
|
||||||
|
/// 对已发布帖重复提交为幂等 no-op,弱网重发不报错)。
|
||||||
|
/// - [media] 三态:null = 缺席不动;`[]` = 清空为纯文字帖;非空 = 整组替换。
|
||||||
|
class UpdatePostRequest {
|
||||||
|
const UpdatePostRequest({
|
||||||
|
required this.version,
|
||||||
|
this.title,
|
||||||
|
this.content,
|
||||||
|
this.category,
|
||||||
|
this.petId,
|
||||||
|
this.publish = false,
|
||||||
|
this.media,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int version;
|
||||||
|
final String? title;
|
||||||
|
final String? content;
|
||||||
|
final PostCategory? category;
|
||||||
|
final String? petId;
|
||||||
|
final bool publish;
|
||||||
|
final List<PostMediaAttachRequest>? media;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
if (title != null) 'title': title,
|
||||||
|
if (content != null) 'content': content,
|
||||||
|
if (category != null) 'category': category!.wire,
|
||||||
|
if (petId != null) 'petId': petId,
|
||||||
|
if (publish) 'status': PostStatus.published.name,
|
||||||
|
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子完整形态(详情 / 我的帖子列表 / 写响应共用)。
|
||||||
|
/// 「内容是否编辑过」以 version 为准(互动计数维护亦会推动 updatedAt)。
|
||||||
|
class Post {
|
||||||
|
const Post({
|
||||||
|
required this.id,
|
||||||
|
required this.author,
|
||||||
|
required this.petId,
|
||||||
|
required this.category,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
required this.status,
|
||||||
|
required this.visibility,
|
||||||
|
required this.media,
|
||||||
|
required this.likeCount,
|
||||||
|
required this.commentCount,
|
||||||
|
required this.bookmarkCount,
|
||||||
|
required this.likedByMe,
|
||||||
|
required this.bookmarkedByMe,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.publishedAt,
|
||||||
|
required this.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Post.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Post(
|
||||||
|
id: json['id'] as String,
|
||||||
|
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
|
||||||
|
petId: json['petId'] as String?,
|
||||||
|
category: PostCategory.fromJson(json['category'] as String),
|
||||||
|
title: json['title'] as String?,
|
||||||
|
content: json['content'] as String,
|
||||||
|
status: PostStatus.fromJson(json['status'] as String),
|
||||||
|
visibility: PostVisibility.fromJson(json['visibility'] as String),
|
||||||
|
media: (json['media'] as List)
|
||||||
|
.map((item) => PostMediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
likeCount: json['likeCount'] as int,
|
||||||
|
commentCount: json['commentCount'] as int,
|
||||||
|
bookmarkCount: json['bookmarkCount'] as int,
|
||||||
|
likedByMe: json['likedByMe'] as bool,
|
||||||
|
bookmarkedByMe: json['bookmarkedByMe'] as bool,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
// 仅 published 非空(发布时恰写一次)。
|
||||||
|
publishedAt: _dateTimeOrNull(json['publishedAt']),
|
||||||
|
version: json['version'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final AuthorSummary author;
|
||||||
|
final String? petId;
|
||||||
|
final PostCategory category;
|
||||||
|
final String? title;
|
||||||
|
final String content;
|
||||||
|
final PostStatus status;
|
||||||
|
final PostVisibility visibility;
|
||||||
|
final List<PostMediaItem> media;
|
||||||
|
final int likeCount;
|
||||||
|
final int commentCount;
|
||||||
|
final int bookmarkCount;
|
||||||
|
final bool likedByMe;
|
||||||
|
final bool bookmarkedByMe;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final DateTime? publishedAt;
|
||||||
|
final int version;
|
||||||
|
|
||||||
|
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
|
||||||
|
Post copyWithInteraction({
|
||||||
|
int? likeCount,
|
||||||
|
int? commentCount,
|
||||||
|
int? bookmarkCount,
|
||||||
|
bool? likedByMe,
|
||||||
|
bool? bookmarkedByMe,
|
||||||
|
}) {
|
||||||
|
return Post(
|
||||||
|
id: id,
|
||||||
|
author: author,
|
||||||
|
petId: petId,
|
||||||
|
category: category,
|
||||||
|
title: title,
|
||||||
|
content: content,
|
||||||
|
status: status,
|
||||||
|
visibility: visibility,
|
||||||
|
media: media,
|
||||||
|
likeCount: likeCount ?? this.likeCount,
|
||||||
|
commentCount: commentCount ?? this.commentCount,
|
||||||
|
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
|
||||||
|
likedByMe: likedByMe ?? this.likedByMe,
|
||||||
|
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
publishedAt: publishedAt,
|
||||||
|
version: version,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed / 收藏列表卡片形态(较 Post 裁剪:只带 coverImage + mediaCount,
|
||||||
|
/// 全文恒走帖子详情端点)。publishedAt 恒非空(谓词只放行 published)。
|
||||||
|
class FeedCard {
|
||||||
|
const FeedCard({
|
||||||
|
required this.id,
|
||||||
|
required this.author,
|
||||||
|
required this.category,
|
||||||
|
required this.title,
|
||||||
|
required this.contentPreview,
|
||||||
|
required this.coverImage,
|
||||||
|
required this.mediaCount,
|
||||||
|
required this.likeCount,
|
||||||
|
required this.commentCount,
|
||||||
|
required this.bookmarkCount,
|
||||||
|
required this.likedByMe,
|
||||||
|
required this.bookmarkedByMe,
|
||||||
|
required this.publishedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FeedCard.fromJson(Map<String, dynamic> json) {
|
||||||
|
final cover = json['coverImage'];
|
||||||
|
return FeedCard(
|
||||||
|
id: json['id'] as String,
|
||||||
|
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
|
||||||
|
category: PostCategory.fromJson(json['category'] as String),
|
||||||
|
title: json['title'] as String?,
|
||||||
|
contentPreview: json['contentPreview'] as String,
|
||||||
|
// 封面 = 库中唯一 is_cover 行;纯文字帖为 null。
|
||||||
|
coverImage: cover == null
|
||||||
|
? null
|
||||||
|
: PostMediaItem.fromJson(cover as Map<String, dynamic>),
|
||||||
|
mediaCount: json['mediaCount'] as int,
|
||||||
|
likeCount: json['likeCount'] as int,
|
||||||
|
commentCount: json['commentCount'] as int,
|
||||||
|
bookmarkCount: json['bookmarkCount'] as int,
|
||||||
|
likedByMe: json['likedByMe'] as bool,
|
||||||
|
bookmarkedByMe: json['bookmarkedByMe'] as bool,
|
||||||
|
publishedAt: DateTime.parse(json['publishedAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final AuthorSummary author;
|
||||||
|
final PostCategory category;
|
||||||
|
final String? title;
|
||||||
|
final String contentPreview;
|
||||||
|
final PostMediaItem? coverImage;
|
||||||
|
final int mediaCount;
|
||||||
|
final int likeCount;
|
||||||
|
final int commentCount;
|
||||||
|
final int bookmarkCount;
|
||||||
|
final bool likedByMe;
|
||||||
|
final bool bookmarkedByMe;
|
||||||
|
final DateTime publishedAt;
|
||||||
|
|
||||||
|
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
|
||||||
|
FeedCard copyWithInteraction({
|
||||||
|
int? likeCount,
|
||||||
|
int? commentCount,
|
||||||
|
int? bookmarkCount,
|
||||||
|
bool? likedByMe,
|
||||||
|
bool? bookmarkedByMe,
|
||||||
|
}) {
|
||||||
|
return FeedCard(
|
||||||
|
id: id,
|
||||||
|
author: author,
|
||||||
|
category: category,
|
||||||
|
title: title,
|
||||||
|
contentPreview: contentPreview,
|
||||||
|
coverImage: coverImage,
|
||||||
|
mediaCount: mediaCount,
|
||||||
|
likeCount: likeCount ?? this.likeCount,
|
||||||
|
commentCount: commentCount ?? this.commentCount,
|
||||||
|
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
|
||||||
|
likedByMe: likedByMe ?? this.likedByMe,
|
||||||
|
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
|
||||||
|
publishedAt: publishedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建评论请求。content trim 后 1~2000;replyToUserId 可选 @ 回复目标
|
||||||
|
/// (单层平铺,无楼中楼)。
|
||||||
|
class CreateCommentRequest {
|
||||||
|
const CreateCommentRequest({required this.content, this.replyToUserId});
|
||||||
|
|
||||||
|
final String content;
|
||||||
|
final String? replyToUserId;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'content': content,
|
||||||
|
if (replyToUserId != null) 'replyToUserId': replyToUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论(M3 无评论编辑,不带 updatedAt)。
|
||||||
|
class PostComment {
|
||||||
|
const PostComment({
|
||||||
|
required this.id,
|
||||||
|
required this.postId,
|
||||||
|
required this.author,
|
||||||
|
required this.replyToUser,
|
||||||
|
required this.content,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PostComment.fromJson(Map<String, dynamic> json) {
|
||||||
|
final replyTo = json['replyToUser'];
|
||||||
|
return PostComment(
|
||||||
|
id: json['id'] as String,
|
||||||
|
postId: json['postId'] as String,
|
||||||
|
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
|
||||||
|
// @ 回复目标公开摘要(含降级 id-only 形态);非回复为 null。
|
||||||
|
replyToUser: replyTo == null
|
||||||
|
? null
|
||||||
|
: AuthorSummary.fromJson(replyTo as Map<String, dynamic>),
|
||||||
|
content: json['content'] as String,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String postId;
|
||||||
|
final AuthorSummary author;
|
||||||
|
final AuthorSummary? replyToUser;
|
||||||
|
final String content;
|
||||||
|
final DateTime createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建上传请求(两步上传第一步)。mimeType 白名单
|
||||||
|
/// image/jpeg|png|webp,byteSize ≤ 10485760(10 MiB,服务端配置项);
|
||||||
|
/// sha256 可选(64 位小写 hex,M3 照收照存不核验)。
|
||||||
|
class CreateMediaUploadRequest {
|
||||||
|
const CreateMediaUploadRequest({
|
||||||
|
required this.kind,
|
||||||
|
required this.purpose,
|
||||||
|
required this.mimeType,
|
||||||
|
required this.byteSize,
|
||||||
|
this.sha256,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MediaKind kind;
|
||||||
|
final MediaPurpose purpose;
|
||||||
|
final String mimeType;
|
||||||
|
final int byteSize;
|
||||||
|
final String? sha256;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'kind': kind.name,
|
||||||
|
'purpose': purpose.wire,
|
||||||
|
'mimeType': mimeType,
|
||||||
|
'byteSize': byteSize,
|
||||||
|
if (sha256 != null) 'sha256': sha256,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预签名直传凭据。凭据(uploadUrl 含签名)TTL 默认 10 分钟,过期后
|
||||||
|
/// 重新创建上传;直传必须原样携带 requiredHeaders(Content-Type 已签进
|
||||||
|
/// 签名,改动即被存储侧拒绝)。凭据会过期,不得持久化。
|
||||||
|
class MediaUploadCredentials {
|
||||||
|
const MediaUploadCredentials({
|
||||||
|
required this.assetId,
|
||||||
|
required this.uploadUrl,
|
||||||
|
required this.method,
|
||||||
|
required this.requiredHeaders,
|
||||||
|
required this.expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MediaUploadCredentials.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MediaUploadCredentials(
|
||||||
|
assetId: json['assetId'] as String,
|
||||||
|
uploadUrl: json['uploadUrl'] as String,
|
||||||
|
method: json['method'] as String,
|
||||||
|
requiredHeaders: (json['requiredHeaders'] as Map<String, dynamic>).map(
|
||||||
|
(key, value) => MapEntry(key, value as String),
|
||||||
|
),
|
||||||
|
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String assetId;
|
||||||
|
final String uploadUrl;
|
||||||
|
|
||||||
|
/// 契约定型恒为 PUT(enum 单值;直传时按本值发起请求)。
|
||||||
|
final String method;
|
||||||
|
final Map<String, String> requiredHeaders;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// media asset(confirm 后的可引用形态)。url 仅 ready 态非空——时效性
|
||||||
|
/// 预签名 GET(TTL 默认 1 小时),每次响应现签,不得持久化、过期即重取。
|
||||||
|
class MediaAsset {
|
||||||
|
const MediaAsset({
|
||||||
|
required this.id,
|
||||||
|
required this.kind,
|
||||||
|
required this.purpose,
|
||||||
|
required this.mimeType,
|
||||||
|
required this.byteSize,
|
||||||
|
required this.widthPx,
|
||||||
|
required this.heightPx,
|
||||||
|
required this.status,
|
||||||
|
required this.url,
|
||||||
|
required this.readyAt,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MediaAsset.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MediaAsset(
|
||||||
|
id: json['id'] as String,
|
||||||
|
kind: MediaKind.fromJson(json['kind'] as String),
|
||||||
|
purpose: json['purpose'] as String,
|
||||||
|
mimeType: json['mimeType'] as String,
|
||||||
|
byteSize: json['byteSize'] as int?,
|
||||||
|
widthPx: json['widthPx'] as int?,
|
||||||
|
heightPx: json['heightPx'] as int?,
|
||||||
|
status: MediaAssetStatus.fromJson(json['status'] as String),
|
||||||
|
url: json['url'] as String?,
|
||||||
|
readyAt: _dateTimeOrNull(json['readyAt']),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final MediaKind kind;
|
||||||
|
final String purpose;
|
||||||
|
final String mimeType;
|
||||||
|
final int? byteSize;
|
||||||
|
final int? widthPx;
|
||||||
|
final int? heightPx;
|
||||||
|
final MediaAssetStatus status;
|
||||||
|
final String? url;
|
||||||
|
final DateTime? readyAt;
|
||||||
|
final DateTime createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞权威终态(乐观更新以此对账回滚,回滚基准取响应值)。
|
||||||
|
class LikeState {
|
||||||
|
const LikeState({required this.liked, required this.likeCount});
|
||||||
|
|
||||||
|
factory LikeState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LikeState(
|
||||||
|
liked: json['liked'] as bool,
|
||||||
|
likeCount: json['likeCount'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool liked;
|
||||||
|
final int likeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏权威终态(与点赞同构)。
|
||||||
|
class BookmarkState {
|
||||||
|
const BookmarkState({required this.bookmarked, required this.bookmarkCount});
|
||||||
|
|
||||||
|
factory BookmarkState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BookmarkState(
|
||||||
|
bookmarked: json['bookmarked'] as bool,
|
||||||
|
bookmarkCount: json['bookmarkCount'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool bookmarked;
|
||||||
|
final int bookmarkCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关注权威终态;followerCount 为目标用户的粉丝数(实时 COUNT)。
|
||||||
|
class FollowState {
|
||||||
|
const FollowState({required this.following, required this.followerCount});
|
||||||
|
|
||||||
|
factory FollowState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FollowState(
|
||||||
|
following: json['following'] as bool,
|
||||||
|
followerCount: json['followerCount'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool following;
|
||||||
|
final int followerCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关注计数(关注数 / 粉丝数 / 我是否已关注;查自己 followedByMe 恒 false)。
|
||||||
|
class FollowStats {
|
||||||
|
const FollowStats({
|
||||||
|
required this.followerCount,
|
||||||
|
required this.followingCount,
|
||||||
|
required this.followedByMe,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FollowStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FollowStats(
|
||||||
|
followerCount: json['followerCount'] as int,
|
||||||
|
followingCount: json['followingCount'] as int,
|
||||||
|
followedByMe: json['followedByMe'] as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int followerCount;
|
||||||
|
final int followingCount;
|
||||||
|
final bool followedByMe;
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// community / media 域仓库接口(契约 v1.3.0 的 13 路径 / 19 操作全覆盖;
|
||||||
|
/// 页面依赖此抽象,widget / controller 测试注入假实现)。
|
||||||
|
///
|
||||||
|
/// media 两步上传本单只到协议层(创建上传 / 确认上传);预签名 PUT
|
||||||
|
/// 直传对象存储不走业务信封与 Bearer 鉴权,属 T3-13 独立客户端。
|
||||||
|
abstract class CommunityRepository {
|
||||||
|
// ---- media 两步上传(协议层)----
|
||||||
|
Future<MediaUploadCredentials> createMediaUpload(
|
||||||
|
CreateMediaUploadRequest request,
|
||||||
|
);
|
||||||
|
Future<MediaAsset> completeMediaUpload(String assetId);
|
||||||
|
|
||||||
|
// ---- 帖子 CRUD / 发布 ----
|
||||||
|
/// [idempotencyKey]:调用方持键(T3-17 发布页「同键重放」——网络失败重试
|
||||||
|
/// 沿用同键命中服务端首帖,不重复建帖;缺省则本层每次调用换新键)。
|
||||||
|
Future<Post> createPost(CreatePostRequest request, {String? idempotencyKey});
|
||||||
|
Future<Post> getPost(String postId);
|
||||||
|
Future<Post> updatePost(String postId, UpdatePostRequest request);
|
||||||
|
Future<void> deletePost(String postId);
|
||||||
|
Future<CursorPage<Post>> listMyPosts({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
PostStatus? status,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Feed(cursor 分页)----
|
||||||
|
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor});
|
||||||
|
|
||||||
|
// ---- 评论 ----
|
||||||
|
Future<CursorPage<PostComment>> listComments(
|
||||||
|
String postId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
});
|
||||||
|
Future<PostComment> createComment(
|
||||||
|
String postId,
|
||||||
|
CreateCommentRequest request,
|
||||||
|
);
|
||||||
|
Future<void> deleteComment(String commentId);
|
||||||
|
|
||||||
|
// ---- 点赞 / 收藏(PUT/DELETE 语义幂等,响应权威终态)----
|
||||||
|
Future<LikeState> likePost(String postId);
|
||||||
|
Future<LikeState> unlikePost(String postId);
|
||||||
|
Future<BookmarkState> bookmarkPost(String postId);
|
||||||
|
Future<BookmarkState> unbookmarkPost(String postId);
|
||||||
|
Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor});
|
||||||
|
|
||||||
|
// ---- 关注 ----
|
||||||
|
Future<FollowState> followUser(String userId);
|
||||||
|
Future<FollowState> unfollowUser(String userId);
|
||||||
|
Future<FollowStats> getFollowStats(String userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
|
||||||
|
/// 拦截 + 401/40101 单飞刷新重放);community 域业务错误码升格为类型化异常。
|
||||||
|
///
|
||||||
|
/// 幂等:createPost / createComment 两个 POST 按契约**必带**
|
||||||
|
/// `Idempotency-Key`(1~128 字符;每次逻辑提交换新键;token 刷新后的
|
||||||
|
/// 自动重放沿用同一个键——键在本层每次调用生成一次,重放走同一 headers)。
|
||||||
|
/// 点赞/收藏/关注为 PUT/DELETE 语义幂等,无需幂等键。
|
||||||
|
///
|
||||||
|
/// 端口线路:community 域端点走 community 服务客户端;media 两步上传
|
||||||
|
/// 端点由 **user 服务**提供(13 号报告 §2,MediaController 在
|
||||||
|
/// patbond-user),经 [mediaApi] 直连——T3-13 真链路实测修正,
|
||||||
|
/// 未提供时回落主客户端(既有测试桩场景)。
|
||||||
|
class ApiCommunityRepository implements CommunityRepository {
|
||||||
|
ApiCommunityRepository({
|
||||||
|
required ApiClient api,
|
||||||
|
ApiClient? mediaApi,
|
||||||
|
this._uuid = const Uuid(),
|
||||||
|
}) : _api = api,
|
||||||
|
_mediaApi = mediaApi ?? api;
|
||||||
|
|
||||||
|
final ApiClient _api;
|
||||||
|
final ApiClient _mediaApi;
|
||||||
|
final Uuid _uuid;
|
||||||
|
|
||||||
|
Future<Object?> _request(
|
||||||
|
String path, {
|
||||||
|
String method = 'GET',
|
||||||
|
Object? body,
|
||||||
|
Map<String, Object?>? query,
|
||||||
|
bool idempotent = false,
|
||||||
|
String? idempotencyKey,
|
||||||
|
bool media = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await (media ? _mediaApi : _api).request(
|
||||||
|
path,
|
||||||
|
method: method,
|
||||||
|
body: body,
|
||||||
|
query: query,
|
||||||
|
headers: idempotent
|
||||||
|
? {'Idempotency-Key': idempotencyKey ?? _uuid.v4()}
|
||||||
|
: null,
|
||||||
|
requiresAuth: true,
|
||||||
|
);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
throw mapCommunityBusinessException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _asMap(Object? data) => data! as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// ---- media ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaUploadCredentials> createMediaUpload(
|
||||||
|
CreateMediaUploadRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/media/uploads',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
media: true,
|
||||||
|
);
|
||||||
|
return MediaUploadCredentials.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaAsset> completeMediaUpload(String assetId) async {
|
||||||
|
// 幂等由服务端保证:已 ready 重复 confirm 返回 200 同一 asset。
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/media/uploads/$assetId/complete',
|
||||||
|
method: 'POST',
|
||||||
|
media: true,
|
||||||
|
);
|
||||||
|
return MediaAsset.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- posts ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> createPost(
|
||||||
|
CreatePostRequest request, {
|
||||||
|
String? idempotencyKey,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
idempotencyKey: idempotencyKey,
|
||||||
|
);
|
||||||
|
return Post.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> getPost(String postId) async {
|
||||||
|
final data = await _request('/api/v1/posts/$postId');
|
||||||
|
return Post.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> updatePost(String postId, UpdatePostRequest request) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return Post.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deletePost(String postId) async {
|
||||||
|
await _request('/api/v1/posts/$postId', method: 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<Post>> listMyPosts({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
PostStatus? status,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/me/posts',
|
||||||
|
query: {
|
||||||
|
'limit': ?limit,
|
||||||
|
'cursor': ?cursor,
|
||||||
|
if (status != null) 'status': status.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), Post.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- feed ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/feed',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), FeedCard.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- comments ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<PostComment>> listComments(
|
||||||
|
String postId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/comments',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), PostComment.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PostComment> createComment(
|
||||||
|
String postId,
|
||||||
|
CreateCommentRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/comments',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return PostComment.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteComment(String commentId) async {
|
||||||
|
// 顶层短路径先例:commentId 全局唯一。
|
||||||
|
await _request('/api/v1/comments/$commentId', method: 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- interactions ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> likePost(String postId) async {
|
||||||
|
final data = await _request('/api/v1/posts/$postId/like', method: 'PUT');
|
||||||
|
return LikeState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> unlikePost(String postId) async {
|
||||||
|
final data = await _request('/api/v1/posts/$postId/like', method: 'DELETE');
|
||||||
|
return LikeState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> bookmarkPost(String postId) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/bookmark',
|
||||||
|
method: 'PUT',
|
||||||
|
);
|
||||||
|
return BookmarkState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> unbookmarkPost(String postId) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/bookmark',
|
||||||
|
method: 'DELETE',
|
||||||
|
);
|
||||||
|
return BookmarkState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> listMyBookmarks({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/me/bookmarks',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), FeedCard.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- follows ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> followUser(String userId) async {
|
||||||
|
final data = await _request('/api/v1/users/$userId/follow', method: 'PUT');
|
||||||
|
return FollowState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> unfollowUser(String userId) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/users/$userId/follow',
|
||||||
|
method: 'DELETE',
|
||||||
|
);
|
||||||
|
return FollowState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowStats> getFollowStats(String userId) async {
|
||||||
|
final data = await _request('/api/v1/users/$userId/follow-stats');
|
||||||
|
return FollowStats.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
|
||||||
|
/// feed 域埋点强类型封装(06 号规划 §1.4 字典 v3;后端白名单已随
|
||||||
|
/// api dev@8089c06 就绪)。沿 pet_analytics 惯例:枚举编译期锁死,
|
||||||
|
/// 业务代码禁止手拼事件名与属性;只记行为不记内容(隐私红线:postId
|
||||||
|
/// 等内容 ID 一律不进 props,曝光去重键只存活于客户端内存)。
|
||||||
|
|
||||||
|
/// feed_viewed / feed_load_failed 的 feedTab 枚举(06 §1.4)。
|
||||||
|
/// M3 T3-14 仅接 home;topic / user_posts / favorites 随后续页面启用。
|
||||||
|
enum FeedTab {
|
||||||
|
home('home'),
|
||||||
|
topic('topic'),
|
||||||
|
userPosts('user_posts'),
|
||||||
|
favorites('favorites');
|
||||||
|
|
||||||
|
const FeedTab(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// feed_load_failed 的 loadType 枚举。
|
||||||
|
enum FeedLoadType {
|
||||||
|
refresh('refresh'),
|
||||||
|
loadMore('load_more');
|
||||||
|
|
||||||
|
const FeedLoadType(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// feed 加载失败原因(06 v3 失败枚举基底)。网络归并口径同 pet 域:
|
||||||
|
/// 断网/超时/5xx 均并入 network_error,server_error 保留兜底。
|
||||||
|
enum FeedLoadFailureReason {
|
||||||
|
rateLimited('rate_limited'),
|
||||||
|
networkError('network_error'),
|
||||||
|
serverError('server_error');
|
||||||
|
|
||||||
|
const FeedLoadFailureReason(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 类型化异常 → 失败原因;会话失效返回 null(应用即将回登录页,
|
||||||
|
/// 不作为 Feed 加载失败上报)。
|
||||||
|
FeedLoadFailureReason? feedLoadFailureReasonOf(ApiException error) =>
|
||||||
|
switch (error) {
|
||||||
|
ApiNetworkException _ => FeedLoadFailureReason.networkError,
|
||||||
|
ApiRateLimitException _ => FeedLoadFailureReason.rateLimited,
|
||||||
|
SessionExpiredException _ => null,
|
||||||
|
_ => FeedLoadFailureReason.serverError,
|
||||||
|
};
|
||||||
|
|
||||||
|
class FeedAnalytics {
|
||||||
|
FeedAnalytics(this._track);
|
||||||
|
|
||||||
|
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
|
||||||
|
final TrackEventFn _track;
|
||||||
|
|
||||||
|
/// 一个 Feed 浏览段的聚合曝光(06 §1.2 裁定):离开 Feed(路由跳走 /
|
||||||
|
/// 退后台)时发一条,携带段内曝光卡片数(≥50% 可见 ≥500ms、按帖去重
|
||||||
|
/// 后的**计数**)、翻页数、刷新数与前台停留时长。
|
||||||
|
void feedViewed({
|
||||||
|
required FeedTab feedTab,
|
||||||
|
required int durationMs,
|
||||||
|
required int impressionCount,
|
||||||
|
required int loadMoreCount,
|
||||||
|
required int refreshCount,
|
||||||
|
}) {
|
||||||
|
_track('feed_viewed', {
|
||||||
|
'feedTab': feedTab.value,
|
||||||
|
'durationMs': durationMs,
|
||||||
|
'impressionCount': impressionCount,
|
||||||
|
'loadMoreCount': loadMoreCount,
|
||||||
|
'refreshCount': refreshCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新或翻页请求失败(M3 验收「分页不丢失不重复」的客户端观测点)。
|
||||||
|
///
|
||||||
|
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位
|
||||||
|
/// 业务码推导(`code ~/ 100`,pet 域同款口径)。
|
||||||
|
void feedLoadFailed({
|
||||||
|
required FeedTab feedTab,
|
||||||
|
required FeedLoadType loadType,
|
||||||
|
required FeedLoadFailureReason reason,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('feed_load_failed', {
|
||||||
|
'feedTab': feedTab.value,
|
||||||
|
'loadType': loadType.value,
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [feedLoadFailed] 的异常直通口:会话失效不上报。
|
||||||
|
void feedLoadFailedFrom(
|
||||||
|
ApiException error, {
|
||||||
|
required FeedTab feedTab,
|
||||||
|
required FeedLoadType loadType,
|
||||||
|
}) {
|
||||||
|
final reason = feedLoadFailureReasonOf(error);
|
||||||
|
if (reason == null) return;
|
||||||
|
feedLoadFailed(
|
||||||
|
feedTab: feedTab,
|
||||||
|
loadType: loadType,
|
||||||
|
reason: reason,
|
||||||
|
errorCode: error is ApiBusinessException ? error.code : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||||
|
|
||||||
|
/// 一个「Feed 浏览段」的聚合器(06 号规划 §1.2 裁定的客户端实现)。
|
||||||
|
///
|
||||||
|
/// 段生命周期由页面驱动:进入 Feed(Tab 激活且分段在 Feed)开段;
|
||||||
|
/// 离开(切 Tab / 切分段 / 退后台 / 页面销毁)时 [settle] 结算并经
|
||||||
|
/// [FeedAnalytics.feedViewed] 发**一条**聚合事件。
|
||||||
|
///
|
||||||
|
/// - 曝光判定:卡片可见面积 ≥ [visibleThreshold] 且持续 ≥ [dwell],
|
||||||
|
/// 段内按 postId 去重;postId 只作内存去重键、绝不上报(隐私红线 2),
|
||||||
|
/// 段结束即弃。
|
||||||
|
/// - durationMs 为前台时长(退后台即结算,段天然前台连续),上限
|
||||||
|
/// 截断 30 分钟防挂机污染(06 §1.4 实现注意)。
|
||||||
|
class FeedViewSegment {
|
||||||
|
FeedViewSegment({DateTime Function()? now})
|
||||||
|
: _now = now ?? DateTime.now,
|
||||||
|
_settled = false {
|
||||||
|
_startedAt = _now();
|
||||||
|
}
|
||||||
|
|
||||||
|
static const visibleThreshold = 0.5;
|
||||||
|
static const dwell = Duration(milliseconds: 500);
|
||||||
|
static const maxDuration = Duration(minutes: 30);
|
||||||
|
|
||||||
|
final DateTime Function() _now;
|
||||||
|
late final DateTime _startedAt;
|
||||||
|
|
||||||
|
final Set<String> _impressed = <String>{};
|
||||||
|
final Map<String, Timer> _dwellTimers = <String, Timer>{};
|
||||||
|
int _loadMoreCount = 0;
|
||||||
|
int _refreshCount = 0;
|
||||||
|
bool _settled;
|
||||||
|
|
||||||
|
/// 段内曝光卡片数(已去重;仅测试与结算读取)。
|
||||||
|
int get impressionCount => _impressed.length;
|
||||||
|
|
||||||
|
/// 可见性回报:≥50% 起 500ms 驻留计时,跌破或滚出视口即取消;
|
||||||
|
/// 驻留满即记曝光(去重后不再计时)。
|
||||||
|
void updateVisibility(String postId, double visibleFraction) {
|
||||||
|
if (_settled) return;
|
||||||
|
if (visibleFraction >= visibleThreshold) {
|
||||||
|
if (_impressed.contains(postId) || _dwellTimers.containsKey(postId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_dwellTimers[postId] = Timer(dwell, () {
|
||||||
|
_dwellTimers.remove(postId);
|
||||||
|
_impressed.add(postId);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_dwellTimers.remove(postId)?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户触发的下拉刷新 / 失败重试(首屏自动预取不计)。
|
||||||
|
void recordRefresh() {
|
||||||
|
if (!_settled) _refreshCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 触底翻页请求(含尾部失败重试)。
|
||||||
|
void recordLoadMore() {
|
||||||
|
if (!_settled) _loadMoreCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结算:取消在途驻留计时并冻结计数;幂等(重复调用返回 null,
|
||||||
|
/// 保证一段恰好一条 feed_viewed)。
|
||||||
|
FeedViewSummary? settle() {
|
||||||
|
if (_settled) return null;
|
||||||
|
_settled = true;
|
||||||
|
for (final timer in _dwellTimers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_dwellTimers.clear();
|
||||||
|
final elapsed = _now().difference(_startedAt);
|
||||||
|
return FeedViewSummary(
|
||||||
|
durationMs: min(elapsed.inMilliseconds, maxDuration.inMilliseconds),
|
||||||
|
impressionCount: _impressed.length,
|
||||||
|
loadMoreCount: _loadMoreCount,
|
||||||
|
refreshCount: _refreshCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [FeedViewSegment.settle] 的结算结果(feed_viewed 专有属性)。
|
||||||
|
class FeedViewSummary {
|
||||||
|
const FeedViewSummary({
|
||||||
|
required this.durationMs,
|
||||||
|
required this.impressionCount,
|
||||||
|
required this.loadMoreCount,
|
||||||
|
required this.refreshCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int durationMs;
|
||||||
|
final int impressionCount;
|
||||||
|
final int loadMoreCount;
|
||||||
|
final int refreshCount;
|
||||||
|
|
||||||
|
/// 结算即上报的便捷口。
|
||||||
|
void report(FeedAnalytics analytics, {FeedTab feedTab = FeedTab.home}) {
|
||||||
|
analytics.feedViewed(
|
||||||
|
feedTab: feedTab,
|
||||||
|
durationMs: durationMs,
|
||||||
|
impressionCount: impressionCount,
|
||||||
|
loadMoreCount: loadMoreCount,
|
||||||
|
refreshCount: refreshCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_picking.dart';
|
||||||
|
|
||||||
|
/// 压缩产物(直传本体)。mimeType 与直传 PUT 的 Content-Type、
|
||||||
|
/// createUpload 登记值三处必须一致(Content-Type 已签进预签名签名)。
|
||||||
|
class CompressedMediaImage {
|
||||||
|
const CompressedMediaImage({required this.bytes, required this.mimeType});
|
||||||
|
|
||||||
|
final Uint8List bytes;
|
||||||
|
final String mimeType;
|
||||||
|
|
||||||
|
int get byteSize => bytes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 压缩抽象:quality 由 MediaUploader 的降质阶梯驱动(80 → 60),
|
||||||
|
/// 单测注入假实现控制产物大小。
|
||||||
|
abstract class MediaImageCompressor {
|
||||||
|
Future<CompressedMediaImage> compress(
|
||||||
|
PickedMediaImage source, {
|
||||||
|
required int quality,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于 flutter_image_compress 的原生压缩实现(03 号评估 §4.1 选型)。
|
||||||
|
///
|
||||||
|
/// 策略(03 号 §4.1 + 13 号报告偏差 #6):长边 ≤2048 重采样、统一转码
|
||||||
|
/// JPEG(服务端 mime 白名单不收 HEIC,客户端统一出 jpeg);
|
||||||
|
/// autoCorrectionAngle 矫正方向后不保留 EXIF——顺带剥离 GPS 定位隐私,
|
||||||
|
/// **不要开 keepExif**。
|
||||||
|
class NativeMediaImageCompressor implements MediaImageCompressor {
|
||||||
|
const NativeMediaImageCompressor({this.maxLongEdge = 2048});
|
||||||
|
|
||||||
|
final int maxLongEdge;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CompressedMediaImage> compress(
|
||||||
|
PickedMediaImage source, {
|
||||||
|
required int quality,
|
||||||
|
}) async {
|
||||||
|
final bytes = await FlutterImageCompress.compressWithList(
|
||||||
|
source.bytes,
|
||||||
|
minWidth: maxLongEdge,
|
||||||
|
minHeight: maxLongEdge,
|
||||||
|
quality: quality,
|
||||||
|
format: CompressFormat.jpeg,
|
||||||
|
autoCorrectionAngle: true,
|
||||||
|
keepExif: false,
|
||||||
|
);
|
||||||
|
return CompressedMediaImage(bytes: bytes, mimeType: 'image/jpeg');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
/// 预签名直传失败。
|
||||||
|
///
|
||||||
|
/// - [statusCode] 为 null 表示网络层失败(断连/超时),可原凭据重试;
|
||||||
|
/// - 403 为存储侧拒绝签名(凭据过期/被改动),须重新 createUpload 换新凭据;
|
||||||
|
/// - 其余状态码按可重试处理(重试走完整重传)。
|
||||||
|
class MediaDirectUploadException implements Exception {
|
||||||
|
const MediaDirectUploadException({this.statusCode, required this.message});
|
||||||
|
|
||||||
|
final int? statusCode;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// 存储侧拒绝了凭据(签名过期/不符),重试前必须换新凭据。
|
||||||
|
bool get isCredentialRejected => statusCode == 403;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MediaDirectUploadException($statusCode): $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预签名 PUT 直传抽象(两步上传第二步的传输层)。
|
||||||
|
///
|
||||||
|
/// 不走业务信封、不带 Bearer 鉴权——鉴权就是 URL 里的签名本身;
|
||||||
|
/// [headers] 即 createUpload 返回的 requiredHeaders,必须原样携带
|
||||||
|
/// (Content-Type 已签进签名,改动即 403)。
|
||||||
|
abstract class MediaDirectUploadClient {
|
||||||
|
Future<void> put({
|
||||||
|
required String url,
|
||||||
|
required Map<String, String> headers,
|
||||||
|
required Uint8List bytes,
|
||||||
|
void Function(int sent, int total)? onProgress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于裸 Dio 的实现:独立实例,无 AuthInterceptor(预签名 URL 携带
|
||||||
|
/// Authorization 头会破坏 SigV4 校验),sendTimeout 按 10 MiB 弱网上限放宽。
|
||||||
|
class DioMediaDirectUploadClient implements MediaDirectUploadClient {
|
||||||
|
DioMediaDirectUploadClient({Dio? dio})
|
||||||
|
: _dio =
|
||||||
|
dio ??
|
||||||
|
Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 5),
|
||||||
|
sendTimeout: const Duration(seconds: 120),
|
||||||
|
receiveTimeout: const Duration(seconds: 30),
|
||||||
|
validateStatus: (_) => true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> put({
|
||||||
|
required String url,
|
||||||
|
required Map<String, String> headers,
|
||||||
|
required Uint8List bytes,
|
||||||
|
void Function(int sent, int total)? onProgress,
|
||||||
|
}) async {
|
||||||
|
final Response<Object?> response;
|
||||||
|
try {
|
||||||
|
response = await _dio.put<Object?>(
|
||||||
|
url,
|
||||||
|
data: Stream<Uint8List>.value(bytes),
|
||||||
|
options: Options(
|
||||||
|
headers: {...headers, Headers.contentLengthHeader: bytes.length},
|
||||||
|
),
|
||||||
|
onSendProgress: onProgress,
|
||||||
|
);
|
||||||
|
} on DioException catch (error) {
|
||||||
|
throw MediaDirectUploadException(message: '直传网络失败:${error.type.name}');
|
||||||
|
}
|
||||||
|
final status = response.statusCode ?? 0;
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
throw MediaDirectUploadException(
|
||||||
|
statusCode: status,
|
||||||
|
message: '存储侧拒绝直传:HTTP $status',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
/// 待上传的一张原始图(选择器产物,压缩前形态)。
|
||||||
|
///
|
||||||
|
/// 只持有内存字节:不落磁盘副本、不携带原始文件路径,配合压缩层剥离
|
||||||
|
/// EXIF(含 GPS)的隐私纪律(03 号评估 §4.1)。
|
||||||
|
class PickedMediaImage {
|
||||||
|
const PickedMediaImage({required this.bytes, this.name});
|
||||||
|
|
||||||
|
/// 原始字节(供压缩层消费与格内缩略预览)。
|
||||||
|
final Uint8List bytes;
|
||||||
|
|
||||||
|
/// 原始文件名(仅诊断用途,不参与上传——objectKey 由服务端生成)。
|
||||||
|
final String? name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图片选择抽象:MediaUploader 只依赖本接口,单测注入假实现,
|
||||||
|
/// widget 测试无需平台通道。
|
||||||
|
abstract class MediaImagePicker {
|
||||||
|
/// 拉起系统选择器,最多返回 [limit] 张;用户取消返回空列表。
|
||||||
|
Future<List<PickedMediaImage>> pickImages({required int limit});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于 image_picker 的系统选择器实现(03 号评估 §4.1 选型:官方维护、
|
||||||
|
/// pickMultiImage 多选,M3 不引入重型相册组件)。
|
||||||
|
class SystemMediaImagePicker implements MediaImagePicker {
|
||||||
|
SystemMediaImagePicker({ImagePicker? picker})
|
||||||
|
: _picker = picker ?? ImagePicker();
|
||||||
|
|
||||||
|
final ImagePicker _picker;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PickedMediaImage>> pickImages({required int limit}) async {
|
||||||
|
// 仅剩一个空位时用单选(pickMultiImage 的 limit 在部分平台要求 ≥2)。
|
||||||
|
final files = limit <= 1
|
||||||
|
? [?await _picker.pickImage(source: ImageSource.gallery)]
|
||||||
|
: await _picker.pickMultiImage(limit: limit);
|
||||||
|
final images = <PickedMediaImage>[];
|
||||||
|
for (final file in files.take(limit)) {
|
||||||
|
images.add(
|
||||||
|
PickedMediaImage(bytes: await file.readAsBytes(), name: file.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_compression.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_picking.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/post_analytics.dart';
|
||||||
|
|
||||||
|
/// 单张图的上传阶段(05 号规范 §3.3 四视觉态的底层状态模型)。
|
||||||
|
///
|
||||||
|
/// 生命周期:queued → compressing → uploading(progress) → confirming
|
||||||
|
/// → ready | failed(retryable?)。ready 是唯一可交付态——**assetId 只在
|
||||||
|
/// ready 态对外可见**(孤儿防护:未 confirm 的 asset 不得被引用)。
|
||||||
|
enum MediaItemPhase {
|
||||||
|
queued,
|
||||||
|
compressing,
|
||||||
|
uploading,
|
||||||
|
confirming,
|
||||||
|
ready,
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单张图的不可变状态快照(UI 消费;内部任务状态见 _UploadTask)。
|
||||||
|
@immutable
|
||||||
|
class MediaUploadItem {
|
||||||
|
const MediaUploadItem({
|
||||||
|
required this.localId,
|
||||||
|
required this.phase,
|
||||||
|
required this.previewBytes,
|
||||||
|
this.progress = 0,
|
||||||
|
this.assetId,
|
||||||
|
this.errorMessage,
|
||||||
|
this.retryable = false,
|
||||||
|
}) : assert(
|
||||||
|
(assetId != null) == (phase == MediaItemPhase.ready),
|
||||||
|
'assetId 与 ready 态严格绑定(孤儿防护)',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 本地稳定标识(重试/删除寻址用,与服务端无关)。
|
||||||
|
final int localId;
|
||||||
|
|
||||||
|
final MediaItemPhase phase;
|
||||||
|
|
||||||
|
/// 缩略预览字节(原图,UI 直接 Image.memory 渲染)。
|
||||||
|
final Uint8List previewBytes;
|
||||||
|
|
||||||
|
/// 直传进度 0..1(uploading 有意义;confirming 视作 1.0)。
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
/// ready 态的可引用 asset 标识;其余态恒为 null(构造期断言兜底)。
|
||||||
|
final String? assetId;
|
||||||
|
|
||||||
|
/// failed 态的用户可读原因。
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
/// failed 态是否可重试(false = 终态,如压缩后仍超限)。
|
||||||
|
final bool retryable;
|
||||||
|
|
||||||
|
bool get isReady => phase == MediaItemPhase.ready;
|
||||||
|
bool get isFailed => phase == MediaItemPhase.failed;
|
||||||
|
bool get isBusy => !isReady && !isFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内部任务:可变状态 + 生命周期旗标(cancelled 后一切在途结果作废)。
|
||||||
|
class _UploadTask {
|
||||||
|
_UploadTask({required this.localId, required this.source});
|
||||||
|
|
||||||
|
final int localId;
|
||||||
|
final PickedMediaImage source;
|
||||||
|
|
||||||
|
MediaItemPhase phase = MediaItemPhase.queued;
|
||||||
|
double progress = 0;
|
||||||
|
String? errorMessage;
|
||||||
|
bool retryable = false;
|
||||||
|
bool cancelled = false;
|
||||||
|
|
||||||
|
/// 本图第几次上传尝试(媒体三段埋点 attemptSeq,从 1 起;retry 递增)。
|
||||||
|
int attemptSeq = 1;
|
||||||
|
|
||||||
|
/// 本次尝试的 started 时刻(succeeded 的 durationMs 口径)。
|
||||||
|
DateTime? attemptStartedAt;
|
||||||
|
|
||||||
|
/// 压缩产物缓存(重试跳过重压缩)。
|
||||||
|
CompressedMediaImage? compressed;
|
||||||
|
|
||||||
|
/// confirm 成功前的服务端 assetId 只以管线局部变量存在,**不落任务
|
||||||
|
/// 状态、不出现在 [MediaUploadItem] 快照**——孤儿防护的结构保证;
|
||||||
|
/// confirm 通过后才写入 [readyAssetId]。
|
||||||
|
String? readyAssetId;
|
||||||
|
|
||||||
|
MediaUploadItem snapshot() => MediaUploadItem(
|
||||||
|
localId: localId,
|
||||||
|
phase: phase,
|
||||||
|
previewBytes: source.bytes,
|
||||||
|
progress: progress,
|
||||||
|
assetId: readyAssetId,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
retryable: retryable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 媒体上传编排器(T3-13,03 号评估 §4.3 冻结接口的定稿实现)。
|
||||||
|
///
|
||||||
|
/// 职责:选图 → 压缩(降质阶梯 80→60,仍超 10 MiB 拒绝为终态失败)→
|
||||||
|
/// createUpload → 预签名 PUT 直传(进度回调;Content-Type 按
|
||||||
|
/// requiredHeaders 原样携带)→ confirm → ready assetId 交付。
|
||||||
|
///
|
||||||
|
/// 语义要点:
|
||||||
|
/// - **顺序保持**:items 顺序 = 加入顺序 = position 语义;并发上传的
|
||||||
|
/// 完成先后不影响顺序([buildAttachRequests] 按当前列表序发号)。
|
||||||
|
/// - **单飞槽位**:至多 [maxConcurrentUploads] 张同时占用网络管线,
|
||||||
|
/// 单图失败不拖垮整批。
|
||||||
|
/// - **凭据过期**:PUT 前过期预检、存储侧 403 各触发一次自动
|
||||||
|
/// re-createUpload(换新 assetId 新凭据);再失败交给手动重试。
|
||||||
|
/// - **失败重试**:retry 复用压缩产物、从 createUpload 全新开始
|
||||||
|
/// (统一覆盖「对象未上传保持 uploading」与「内容不符置 failed 终态」
|
||||||
|
/// 两种服务端分支——旧 asset 弃引用,由服务端超时清理兜底)。
|
||||||
|
/// - **孤儿防护**:未 confirm 的 assetId 只以管线局部变量存在;
|
||||||
|
/// 对外可见的 [MediaUploadItem.assetId] 与 ready 态严格绑定(断言),
|
||||||
|
/// [buildAttachRequests] 仅在全员 ready 时可用。
|
||||||
|
/// - 预签名凭据只存内存、用完即弃,不持久化(既有纪律)。
|
||||||
|
/// - **媒体三段埋点**(T3-17):每次尝试恰一条 started,收敛为
|
||||||
|
/// succeeded / failed 各一条;`sizeBucket` 统一取原图字节数。
|
||||||
|
class MediaUploader extends ChangeNotifier {
|
||||||
|
MediaUploader({
|
||||||
|
required this._repository,
|
||||||
|
MediaImagePicker? picker,
|
||||||
|
MediaImageCompressor? compressor,
|
||||||
|
MediaDirectUploadClient? directUpload,
|
||||||
|
this._analytics,
|
||||||
|
this.maxImages = 9,
|
||||||
|
this.maxConcurrentUploads = 2,
|
||||||
|
this.maxByteSize = 10 * 1024 * 1024,
|
||||||
|
DateTime Function()? now,
|
||||||
|
}) : _picker = picker ?? SystemMediaImagePicker(),
|
||||||
|
_compressor = compressor ?? const NativeMediaImageCompressor(),
|
||||||
|
_directUpload = directUpload ?? DioMediaDirectUploadClient(),
|
||||||
|
_now = now ?? DateTime.now,
|
||||||
|
_slots = maxConcurrentUploads;
|
||||||
|
|
||||||
|
final CommunityRepository _repository;
|
||||||
|
final MediaImagePicker _picker;
|
||||||
|
final MediaImageCompressor _compressor;
|
||||||
|
final MediaDirectUploadClient _directUpload;
|
||||||
|
|
||||||
|
/// 媒体上传三段埋点(T3-17 接入;未注入即不上报)。
|
||||||
|
final PostAnalytics? _analytics;
|
||||||
|
|
||||||
|
final DateTime Function() _now;
|
||||||
|
|
||||||
|
/// 九宫格上限(05 号规范 §3.2)。
|
||||||
|
final int maxImages;
|
||||||
|
|
||||||
|
final int maxConcurrentUploads;
|
||||||
|
|
||||||
|
/// 与服务端 byteSize 上限一致(10 MiB,13 号报告偏差 #7)。
|
||||||
|
final int maxByteSize;
|
||||||
|
|
||||||
|
/// 凭据过期预检安全边距:距 expiresAt 不足此值即视为过期,直接换新。
|
||||||
|
static const credentialsSafetyMargin = Duration(seconds: 30);
|
||||||
|
|
||||||
|
/// 压缩降质阶梯(80 常规 → 60 兜底;仍超限即终态拒绝)。
|
||||||
|
static const qualityLadder = [80, 60];
|
||||||
|
|
||||||
|
final List<_UploadTask> _tasks = [];
|
||||||
|
int _nextLocalId = 1;
|
||||||
|
bool _picking = false;
|
||||||
|
|
||||||
|
int _slots;
|
||||||
|
final List<Completer<void>> _slotWaiters = [];
|
||||||
|
|
||||||
|
// ---- 对外状态 ----
|
||||||
|
|
||||||
|
/// 选择器是否拉起中(uploader 级 picking 态)。
|
||||||
|
bool get isPicking => _picking;
|
||||||
|
|
||||||
|
List<MediaUploadItem> get items =>
|
||||||
|
List.unmodifiable(_tasks.map((task) => task.snapshot()));
|
||||||
|
|
||||||
|
bool get isEmpty => _tasks.isEmpty;
|
||||||
|
int get remainingSlots => maxImages - _tasks.length;
|
||||||
|
int get readyCount =>
|
||||||
|
_tasks.where((t) => t.phase == MediaItemPhase.ready).length;
|
||||||
|
bool get allReady => _tasks.isNotEmpty && readyCount == _tasks.length;
|
||||||
|
bool get hasFailure => _tasks.any((t) => t.phase == MediaItemPhase.failed);
|
||||||
|
bool get hasBusyItem => _picking || _tasks.any((t) => t.snapshot().isBusy);
|
||||||
|
|
||||||
|
/// 页级汇总进度(05 号规范 §3.3 线性进度条):各图等权。
|
||||||
|
double get overallProgress {
|
||||||
|
if (_tasks.isEmpty) return 0;
|
||||||
|
var sum = 0.0;
|
||||||
|
for (final task in _tasks) {
|
||||||
|
sum += switch (task.phase) {
|
||||||
|
MediaItemPhase.ready => 1.0,
|
||||||
|
MediaItemPhase.uploading => task.progress,
|
||||||
|
MediaItemPhase.confirming => 1.0,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return sum / _tasks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 交付口(T3-17 发布页组 CreatePostRequest.media 用):
|
||||||
|
/// 仅全员 ready 时可用——**任何非 ready 项在场即抛 [StateError]**,
|
||||||
|
/// 从类型上杜绝未 confirm asset 被引用。position 按当前列表序 0..n-1。
|
||||||
|
List<PostMediaAttachRequest> buildAttachRequests({int coverIndex = 0}) {
|
||||||
|
if (!allReady) {
|
||||||
|
throw StateError('存在未就绪的上传项,不得引用(孤儿防护)');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final (index, task) in _tasks.indexed)
|
||||||
|
PostMediaAttachRequest(
|
||||||
|
assetId: task.readyAssetId!,
|
||||||
|
position: index,
|
||||||
|
isCover: index == coverIndex,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 操作 ----
|
||||||
|
|
||||||
|
/// 拉起系统选择器并把所选图片加入上传管线(剩余槽位自动截断)。
|
||||||
|
Future<void> pickAndAdd() async {
|
||||||
|
if (_picking || remainingSlots <= 0) return;
|
||||||
|
_picking = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final images = await _picker.pickImages(limit: remainingSlots);
|
||||||
|
_picking = false;
|
||||||
|
addImages(images);
|
||||||
|
} catch (_) {
|
||||||
|
_picking = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 直接加入图片(选择器旁路,测试与分享外链场景用)。
|
||||||
|
void addImages(List<PickedMediaImage> images) {
|
||||||
|
for (final image in images.take(remainingSlots)) {
|
||||||
|
final task = _UploadTask(localId: _nextLocalId++, source: image);
|
||||||
|
_tasks.add(task);
|
||||||
|
unawaited(_run(task));
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重试一张可重试失败图:复用压缩产物,从 createUpload 全新开始。
|
||||||
|
void retry(int localId) {
|
||||||
|
final task = _taskOrNull(localId);
|
||||||
|
if (task == null ||
|
||||||
|
task.phase != MediaItemPhase.failed ||
|
||||||
|
!task.retryable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
task.errorMessage = null;
|
||||||
|
task.retryable = false;
|
||||||
|
task.progress = 0;
|
||||||
|
task.attemptSeq += 1;
|
||||||
|
task.phase = MediaItemPhase.queued;
|
||||||
|
notifyListeners();
|
||||||
|
unawaited(_run(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 移除一张图(任意态可移除);在途请求结果一律作废,未 confirm 的
|
||||||
|
/// 服务端 asset 弃引用(服务端超时清理兜底)。在途任务被移除按
|
||||||
|
/// `cancelled` 上报一条上传失败(06 §1.4「用户取消」口径)。
|
||||||
|
void remove(int localId) {
|
||||||
|
final task = _taskOrNull(localId);
|
||||||
|
if (task == null) return;
|
||||||
|
_reportCancelled(task);
|
||||||
|
task.cancelled = true;
|
||||||
|
_tasks.remove(task);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空全部(发布成功/离开页面时调用);在途任务同 [remove] 记 cancelled。
|
||||||
|
void reset() {
|
||||||
|
for (final task in _tasks) {
|
||||||
|
_reportCancelled(task);
|
||||||
|
task.cancelled = true;
|
||||||
|
}
|
||||||
|
_tasks.clear();
|
||||||
|
_picking = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
_UploadTask? _taskOrNull(int localId) {
|
||||||
|
for (final task in _tasks) {
|
||||||
|
if (task.localId == localId) return task;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 管线 ----
|
||||||
|
|
||||||
|
Future<void> _run(_UploadTask task) async {
|
||||||
|
await _acquireSlot();
|
||||||
|
try {
|
||||||
|
if (task.cancelled) return;
|
||||||
|
// 一次尝试恰一条 started(含压缩段:压缩失败也在漏斗内可见)。
|
||||||
|
task.attemptStartedAt = _now();
|
||||||
|
_analytics?.mediaUploadStarted(
|
||||||
|
mediaType: MediaType.image,
|
||||||
|
byteSize: task.source.bytes.length,
|
||||||
|
);
|
||||||
|
final compressed = await _compress(task);
|
||||||
|
if (compressed == null || task.cancelled) return;
|
||||||
|
await _uploadAndConfirm(task, compressed);
|
||||||
|
} finally {
|
||||||
|
_releaseSlot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 压缩(降质阶梯);超限终态失败返回 null。
|
||||||
|
Future<CompressedMediaImage?> _compress(_UploadTask task) async {
|
||||||
|
final cached = task.compressed;
|
||||||
|
if (cached != null) return cached;
|
||||||
|
_transition(task, MediaItemPhase.compressing);
|
||||||
|
try {
|
||||||
|
for (final quality in qualityLadder) {
|
||||||
|
final result = await _compressor.compress(
|
||||||
|
task.source,
|
||||||
|
quality: quality,
|
||||||
|
);
|
||||||
|
if (task.cancelled) return null;
|
||||||
|
if (result.byteSize <= maxByteSize) {
|
||||||
|
task.compressed = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: '图片处理失败',
|
||||||
|
retryable: true,
|
||||||
|
reason: MediaUploadFailureReason.unsupportedFormat,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: '图片过大,压缩后仍超过 10 MB',
|
||||||
|
retryable: false,
|
||||||
|
reason: MediaUploadFailureReason.mediaTooLarge,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadAndConfirm(
|
||||||
|
_UploadTask task,
|
||||||
|
CompressedMediaImage compressed,
|
||||||
|
) async {
|
||||||
|
// createUpload:登记 mime/byteSize,取预签名 PUT 凭据(内存态,不持久化)。
|
||||||
|
MediaUploadCredentials credentials;
|
||||||
|
try {
|
||||||
|
credentials = await _createUpload(compressed);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
if (!task.cancelled) _failFromApi(task, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task.cancelled) return;
|
||||||
|
|
||||||
|
// 直传 PUT:过期预检与存储侧 403 各允许一次自动换新凭据。
|
||||||
|
_transition(task, MediaItemPhase.uploading);
|
||||||
|
var renewed = false;
|
||||||
|
while (true) {
|
||||||
|
if (_credentialsExpired(credentials)) {
|
||||||
|
if (renewed) {
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: '上传凭据已过期',
|
||||||
|
retryable: true,
|
||||||
|
reason: MediaUploadFailureReason.serverError,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renewed = true;
|
||||||
|
try {
|
||||||
|
credentials = await _createUpload(compressed);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
if (!task.cancelled) _failFromApi(task, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task.cancelled) return;
|
||||||
|
// 回到循环顶复检新凭据(服务端时钟异常仍过期即失败,不无限重取)。
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _directUpload.put(
|
||||||
|
url: credentials.uploadUrl,
|
||||||
|
headers: credentials.requiredHeaders,
|
||||||
|
bytes: compressed.bytes,
|
||||||
|
onProgress: (sent, total) {
|
||||||
|
if (task.cancelled || total <= 0) return;
|
||||||
|
task.progress = sent / total;
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} on MediaDirectUploadException catch (error) {
|
||||||
|
if (task.cancelled) return;
|
||||||
|
if (error.isCredentialRejected && !renewed) {
|
||||||
|
renewed = true;
|
||||||
|
try {
|
||||||
|
credentials = await _createUpload(compressed);
|
||||||
|
} on Exception catch (creationError) {
|
||||||
|
_failFromApi(task, creationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task.cancelled) return;
|
||||||
|
task.progress = 0;
|
||||||
|
notifyListeners();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: error.statusCode == null ? '网络中断,上传失败' : '上传被存储服务拒绝',
|
||||||
|
retryable: true,
|
||||||
|
reason: error.statusCode == null
|
||||||
|
? MediaUploadFailureReason.networkError
|
||||||
|
: MediaUploadFailureReason.serverError,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (task.cancelled) return;
|
||||||
|
|
||||||
|
// confirm:42205 对象未上传保持 uploading / 内容不符置 failed 终态,
|
||||||
|
// 客户端统一按「可重试 + 重试换新 asset」处理,两分支都正确收敛。
|
||||||
|
_transition(task, MediaItemPhase.confirming);
|
||||||
|
final MediaAsset asset;
|
||||||
|
try {
|
||||||
|
asset = await _repository.completeMediaUpload(credentials.assetId);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
if (!task.cancelled) _failFromApi(task, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task.cancelled) return;
|
||||||
|
if (asset.status != MediaAssetStatus.ready) {
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: '上传确认未通过',
|
||||||
|
retryable: true,
|
||||||
|
reason: MediaUploadFailureReason.serverError,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
task.readyAssetId = asset.id;
|
||||||
|
task.progress = 1;
|
||||||
|
_transition(task, MediaItemPhase.ready);
|
||||||
|
final startedAt = task.attemptStartedAt;
|
||||||
|
_analytics?.mediaUploadSucceeded(
|
||||||
|
mediaType: MediaType.image,
|
||||||
|
byteSize: task.source.bytes.length,
|
||||||
|
durationMs: startedAt == null
|
||||||
|
? 0
|
||||||
|
: _now().difference(startedAt).inMilliseconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<MediaUploadCredentials> _createUpload(
|
||||||
|
CompressedMediaImage compressed,
|
||||||
|
) {
|
||||||
|
return _repository.createMediaUpload(
|
||||||
|
CreateMediaUploadRequest(
|
||||||
|
kind: MediaKind.image,
|
||||||
|
purpose: MediaPurpose.postImage,
|
||||||
|
mimeType: compressed.mimeType,
|
||||||
|
byteSize: compressed.byteSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _credentialsExpired(MediaUploadCredentials credentials) =>
|
||||||
|
!_now().add(credentialsSafetyMargin).isBefore(credentials.expiresAt);
|
||||||
|
|
||||||
|
void _failFromApi(_UploadTask task, Exception error) {
|
||||||
|
// 参数被服务端拒绝(40000:mime/byteSize 白名单外)重试无意义,终态。
|
||||||
|
final isParamError =
|
||||||
|
error is ApiBusinessException && error.code == ApiCodes.paramError;
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: error is ApiBusinessException ? error.message : '网络异常,请重试',
|
||||||
|
retryable: !isParamError,
|
||||||
|
// 客户端已本地保证 ≤10 MiB,故 40000 归因为格式白名单外;
|
||||||
|
// 会话失效不上报(reason 传 null),其余业务/限流并入 server_error。
|
||||||
|
reason: switch (error) {
|
||||||
|
ApiBusinessException _ when isParamError =>
|
||||||
|
MediaUploadFailureReason.unsupportedFormat,
|
||||||
|
SessionExpiredException _ => null,
|
||||||
|
ApiNetworkException _ => MediaUploadFailureReason.networkError,
|
||||||
|
_ => MediaUploadFailureReason.serverError,
|
||||||
|
},
|
||||||
|
errorCode: error is ApiBusinessException ? error.code : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fail(
|
||||||
|
_UploadTask task, {
|
||||||
|
required String message,
|
||||||
|
required bool retryable,
|
||||||
|
required MediaUploadFailureReason? reason,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
if (task.cancelled) return;
|
||||||
|
task.phase = MediaItemPhase.failed;
|
||||||
|
task.errorMessage = message;
|
||||||
|
task.retryable = retryable;
|
||||||
|
if (reason != null) {
|
||||||
|
_analytics?.mediaUploadFailed(
|
||||||
|
mediaType: MediaType.image,
|
||||||
|
byteSize: task.source.bytes.length,
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: task.attemptSeq,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在途任务被删格/清空作废 → `cancelled`(已 ready / 已 failed 不报)。
|
||||||
|
void _reportCancelled(_UploadTask task) {
|
||||||
|
if (task.cancelled || !task.snapshot().isBusy) return;
|
||||||
|
_analytics?.mediaUploadFailed(
|
||||||
|
mediaType: MediaType.image,
|
||||||
|
byteSize: task.source.bytes.length,
|
||||||
|
reason: MediaUploadFailureReason.cancelled,
|
||||||
|
attemptSeq: task.attemptSeq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _transition(_UploadTask task, MediaItemPhase phase) {
|
||||||
|
if (task.cancelled) return;
|
||||||
|
task.phase = phase;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _acquireSlot() {
|
||||||
|
if (_slots > 0) {
|
||||||
|
_slots--;
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
final waiter = Completer<void>();
|
||||||
|
_slotWaiters.add(waiter);
|
||||||
|
return waiter.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _releaseSlot() {
|
||||||
|
if (_slotWaiters.isNotEmpty) {
|
||||||
|
_slotWaiters.removeAt(0).complete();
|
||||||
|
} else {
|
||||||
|
_slots++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [MediaUploader] 的构造口(发布页每次进入建一个,退出即 dispose)。
|
||||||
|
///
|
||||||
|
/// 生产缺省即 `MediaUploader(repository: ..., analytics: ...)`;注入点为
|
||||||
|
/// **测试与桌面实测专用**——Linux 桌面既无 image_picker 也无
|
||||||
|
/// flutter_image_compress 的原生实现,桌面真链路只替换选图与压缩两层,
|
||||||
|
/// 其余(createUpload / 直传 PUT / confirm)全为生产实现。
|
||||||
|
typedef MediaUploaderFactory =
|
||||||
|
MediaUploader Function(
|
||||||
|
CommunityRepository repository,
|
||||||
|
PostAnalytics? analytics,
|
||||||
|
);
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'
|
||||||
|
show textLengthBucketOf;
|
||||||
|
|
||||||
|
/// post 域埋点强类型封装(06 号规划 §1.4 发布漏斗五事件 + 媒体上传三段;
|
||||||
|
/// 后端白名单随 api dev@`8089c06` 就绪,22 号报告 §1 键集逐一对齐)。
|
||||||
|
/// 沿 pet/feed/互动域惯例:枚举编译期锁死,业务代码禁止手拼事件名与属性。
|
||||||
|
///
|
||||||
|
/// 隐私纪律(06 §1.3 红线):正文只出分桶不出字数(红线 1);postId /
|
||||||
|
/// assetId 等内容 ID 一律不进 props(红线 2);媒体只报 [MediaType] 与
|
||||||
|
/// [mediaSizeBucketOf] 分桶,文件名/路径/URL 禁止(红线 4)。
|
||||||
|
///
|
||||||
|
/// **不得上报**(22 号 §1 末段锁死为 unknown):`post_impression`、
|
||||||
|
/// `post_viewed`、`post_like_failed` 等——本文件不提供其封装。
|
||||||
|
|
||||||
|
/// 发帖入口(06 §1.4 `post_create_started.entryPoint`)。
|
||||||
|
/// M3 接 create_tab / feed;topic_detail / pet_detail 随后续页面启用。
|
||||||
|
enum PostEntryPoint {
|
||||||
|
createTab('create_tab'),
|
||||||
|
feed('feed'),
|
||||||
|
topicDetail('topic_detail'),
|
||||||
|
petDetail('pet_detail');
|
||||||
|
|
||||||
|
const PostEntryPoint(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 草稿保存触发方式(06 §1.4:**自动保存不埋**,防高频)。
|
||||||
|
enum DraftSaveTrigger {
|
||||||
|
/// 「存草稿」按钮显式保存。
|
||||||
|
manual('manual'),
|
||||||
|
|
||||||
|
/// 离开发布页时经「保留草稿?」确认保存。
|
||||||
|
onExit('on_exit');
|
||||||
|
|
||||||
|
const DraftSaveTrigger(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布失败原因(06 §1.4 枚举;`content_rejected` 待拍板未启用,
|
||||||
|
/// `not_found` 取 §1.4「失败枚举基底」的复用条——草稿已被别处删除)。
|
||||||
|
enum PostPublishFailureReason {
|
||||||
|
validationError('validation_error'),
|
||||||
|
mediaUploadIncomplete('media_upload_incomplete'),
|
||||||
|
notFound('not_found'),
|
||||||
|
rateLimited('rate_limited'),
|
||||||
|
networkError('network_error'),
|
||||||
|
serverError('server_error');
|
||||||
|
|
||||||
|
const PostPublishFailureReason(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 媒体类型(M3 仅 image;video 随视频能力启用)。
|
||||||
|
enum MediaType {
|
||||||
|
image('image'),
|
||||||
|
video('video');
|
||||||
|
|
||||||
|
const MediaType(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单文件上传失败原因(06 §1.4 媒体漏斗枚举)。
|
||||||
|
enum MediaUploadFailureReason {
|
||||||
|
mediaTooLarge('media_too_large'),
|
||||||
|
unsupportedFormat('unsupported_format'),
|
||||||
|
networkError('network_error'),
|
||||||
|
serverError('server_error'),
|
||||||
|
|
||||||
|
/// 用户在上传途中删格 / 离开发布页作废在途任务。
|
||||||
|
cancelled('cancelled');
|
||||||
|
|
||||||
|
const MediaUploadFailureReason(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 类型化异常 → 发布失败原因;会话失效返回 null(应用即将回登录页,
|
||||||
|
/// 不作为发布失败上报,feed / 互动域同款口径)。
|
||||||
|
///
|
||||||
|
/// 映射取舍(26 号报告 §3 有对照表):42203 恰为
|
||||||
|
/// [PostPublishFailureReason.mediaUploadIncomplete];40905(同键异
|
||||||
|
/// payload)归 validation_error——提交内容与幂等键不一致属提交侧问题,
|
||||||
|
/// 非服务端故障;40902(乐观锁,自动刷新 version 重提仍失败)归
|
||||||
|
/// server_error 兜底。
|
||||||
|
PostPublishFailureReason? postPublishFailureReasonOf(ApiException error) =>
|
||||||
|
switch (error) {
|
||||||
|
ApiNetworkException _ => PostPublishFailureReason.networkError,
|
||||||
|
ApiRateLimitException _ => PostPublishFailureReason.rateLimited,
|
||||||
|
SessionExpiredException _ => null,
|
||||||
|
ApiBusinessException(:final code) => switch (code) {
|
||||||
|
ApiCodes.paramError || ApiCodes.idempotencyKeyMismatch =>
|
||||||
|
PostPublishFailureReason.validationError,
|
||||||
|
ApiCodes.mediaNotReady =>
|
||||||
|
PostPublishFailureReason.mediaUploadIncomplete,
|
||||||
|
ApiCodes.postNotFound ||
|
||||||
|
ApiCodes.mediaNotFound => PostPublishFailureReason.notFound,
|
||||||
|
_ => PostPublishFailureReason.serverError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 媒体大小分桶(06 §1.3 红线 4:不报精确字节数)。
|
||||||
|
/// `lt_1mb` / `mb_1_5` / `mb_5_20` / `gte_20mb`,以 MiB 为界
|
||||||
|
/// (与 MediaUploader 的 10 MiB 上限同一进制)。
|
||||||
|
String mediaSizeBucketOf(int byteSize) {
|
||||||
|
const mib = 1024 * 1024;
|
||||||
|
if (byteSize < mib) return 'lt_1mb';
|
||||||
|
if (byteSize < 5 * mib) return 'mb_1_5';
|
||||||
|
if (byteSize < 20 * mib) return 'mb_5_20';
|
||||||
|
return 'gte_20mb';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布漏斗 + 媒体上传三段埋点(22 号白名单 v3 事件 22~29)。
|
||||||
|
class PostAnalytics {
|
||||||
|
PostAnalytics(this._track);
|
||||||
|
|
||||||
|
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
|
||||||
|
final TrackEventFn _track;
|
||||||
|
|
||||||
|
// ---- 发布漏斗 ----
|
||||||
|
|
||||||
|
/// 进入发布页并产生**首次输入**(首个字符或首次选媒体),每次进入记一次。
|
||||||
|
/// 草稿恢复不算输入(非用户动作,不上报)。
|
||||||
|
void postCreateStarted({required PostEntryPoint entryPoint}) {
|
||||||
|
_track('post_create_started', {'entryPoint': entryPoint.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 草稿保存**成功响应后**;仅显式保存与离开时保存(自动保存不埋)。
|
||||||
|
void postDraftSaved({
|
||||||
|
required DraftSaveTrigger trigger,
|
||||||
|
required int mediaCount,
|
||||||
|
}) {
|
||||||
|
_track('post_draft_saved', {
|
||||||
|
'trigger': trigger.value,
|
||||||
|
'mediaCount': mediaCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布成功响应后(漏斗事件,H5/H6 核心数据源)。
|
||||||
|
///
|
||||||
|
/// [durationMs]:`post_create_started` → 发布成功;[textLength] 经
|
||||||
|
/// [textLengthBucketOf] 分桶后上报,精确字数不出端;[fromDraft] 指
|
||||||
|
/// 「本次发布基于先前保存/恢复的草稿」(发布内部的建草稿→迁移两步
|
||||||
|
/// 不算,见 26 号报告 §3)。
|
||||||
|
void postPublishSucceeded({
|
||||||
|
required int durationMs,
|
||||||
|
required int mediaCount,
|
||||||
|
required int topicCount,
|
||||||
|
required int textLength,
|
||||||
|
required bool fromDraft,
|
||||||
|
}) {
|
||||||
|
_track('post_publish_succeeded', {
|
||||||
|
'durationMs': durationMs,
|
||||||
|
'mediaCount': mediaCount,
|
||||||
|
'topicCount': topicCount,
|
||||||
|
'textLengthBucket': textLengthBucketOf(textLength),
|
||||||
|
'fromDraft': fromDraft,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布失败 / 超时 / 本地校验拦截。
|
||||||
|
///
|
||||||
|
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位业务码
|
||||||
|
/// 推导(`code ~/ 100`,pet 域同款);[attemptSeq] 为本次发布会话内第几次
|
||||||
|
/// 尝试(从 1 起,发布成功或离开发布页后重置)。
|
||||||
|
void postPublishFailed({
|
||||||
|
required PostPublishFailureReason reason,
|
||||||
|
required int attemptSeq,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('post_publish_failed', {
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'attemptSeq': attemptSeq,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删帖成功响应后(单事件风格,无专有属性;失败靠服务端错误率观测)。
|
||||||
|
/// M3 触点:发布页「不保留草稿」删除服务端草稿。
|
||||||
|
void postDeleted() {
|
||||||
|
_track('post_deleted', const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 媒体上传三段(逐文件)----
|
||||||
|
|
||||||
|
/// 单个文件开始上传(一次尝试恰一条;重试各记一条)。
|
||||||
|
///
|
||||||
|
/// [byteSize] 取**选图原文件**字节数,保证同一次尝试三段事件的
|
||||||
|
/// `sizeBucket` 一致(压缩产物大小不另开一套桶)。
|
||||||
|
void mediaUploadStarted({
|
||||||
|
required MediaType mediaType,
|
||||||
|
required int byteSize,
|
||||||
|
}) {
|
||||||
|
_track('post_media_upload_started', {
|
||||||
|
'mediaType': mediaType.value,
|
||||||
|
'sizeBucket': mediaSizeBucketOf(byteSize),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单文件上传成功(confirm 返回 ready 后)。
|
||||||
|
/// [durationMs]:本次尝试 started → ready。
|
||||||
|
void mediaUploadSucceeded({
|
||||||
|
required MediaType mediaType,
|
||||||
|
required int byteSize,
|
||||||
|
required int durationMs,
|
||||||
|
}) {
|
||||||
|
_track('post_media_upload_succeeded', {
|
||||||
|
'mediaType': mediaType.value,
|
||||||
|
'sizeBucket': mediaSizeBucketOf(byteSize),
|
||||||
|
'durationMs': durationMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单文件失败 / 超时 / 用户取消。
|
||||||
|
void mediaUploadFailed({
|
||||||
|
required MediaType mediaType,
|
||||||
|
required int byteSize,
|
||||||
|
required MediaUploadFailureReason reason,
|
||||||
|
required int attemptSeq,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('post_media_upload_failed', {
|
||||||
|
'mediaType': mediaType.value,
|
||||||
|
'sizeBucket': mediaSizeBucketOf(byteSize),
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'attemptSeq': attemptSeq,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,704 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/post_analytics.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// 发布页(P3,05 号规范 §2.3;T3-17 真实数据整页落地)。
|
||||||
|
///
|
||||||
|
/// 结构:媒体选择区([PostMediaEditGrid] 组装 [MediaUploader])→ 正文
|
||||||
|
/// → 类目(general / help)→ 位置占位;AppBar 三件套「取消 / 发布动态 /
|
||||||
|
/// 发布」,另置「存草稿」。
|
||||||
|
///
|
||||||
|
/// 两条提交路径(15 号后端语义 §2.4):
|
||||||
|
///
|
||||||
|
/// - **直接发布** = `createPost(status: draft)` 建草稿 → `PATCH
|
||||||
|
/// {status: published}` 迁移发布。两步而非「一步建 published」是为了让
|
||||||
|
/// 「发布失败但草稿已保存」成为事实而不是话术:迁移这一步失败时草稿
|
||||||
|
/// 已在服务端,用户内容不会丢。
|
||||||
|
/// - **存草稿退出** = 同一个 `createPost(status: draft)`(或对已有草稿
|
||||||
|
/// `PATCH`),随后离页。
|
||||||
|
///
|
||||||
|
/// 幂等纪律:建草稿的 `Idempotency-Key` 由**本页持有**——网络失败重试
|
||||||
|
/// 沿用同键(服务端命中首帖,不重复建帖);表单一经改动即换新键
|
||||||
|
/// (避免「同键异 payload」的 40905 常态化)。
|
||||||
|
///
|
||||||
|
/// 草稿管理最小实现:进页拉取「我的草稿」最新一条并恢复(05 §2.3 的
|
||||||
|
/// 「已恢复上次草稿」提示条),完整草稿列表页留待(26 号报告 §7)。
|
||||||
|
class PostComposePage extends StatefulWidget {
|
||||||
|
const PostComposePage({
|
||||||
|
required this.controller,
|
||||||
|
super.key,
|
||||||
|
this.entryPoint = PostEntryPoint.createTab,
|
||||||
|
this.analytics,
|
||||||
|
this.uploaderFactory,
|
||||||
|
this.now,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tab 级单例(app.dart 装配):发布成功后由调用方触发 Feed 刷新。
|
||||||
|
final CommunityController controller;
|
||||||
|
|
||||||
|
/// 入口归因(`post_create_started.entryPoint`)。
|
||||||
|
final PostEntryPoint entryPoint;
|
||||||
|
|
||||||
|
/// post 域埋点(发布漏斗五事件 + 媒体三段,媒体段经 [MediaUploader])。
|
||||||
|
final PostAnalytics? analytics;
|
||||||
|
|
||||||
|
/// [MediaUploader] 构造口(测试 / 桌面实测替换选图与压缩层)。
|
||||||
|
final MediaUploaderFactory? uploaderFactory;
|
||||||
|
|
||||||
|
/// 时钟注入口(durationMs 断言用;缺省取当前时间)。
|
||||||
|
final DateTime Function()? now;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PostComposePage> createState() => _PostComposePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PostComposePageState extends State<PostComposePage> {
|
||||||
|
static const _maxContentLength = 1000;
|
||||||
|
|
||||||
|
final _contentController = TextEditingController();
|
||||||
|
final _uuid = const Uuid();
|
||||||
|
|
||||||
|
late final MediaUploader _uploader;
|
||||||
|
|
||||||
|
PostCategory _category = PostCategory.general;
|
||||||
|
|
||||||
|
/// 服务端草稿标识与乐观锁版本(建草稿成功或恢复草稿后非空)。
|
||||||
|
String? _draftPostId;
|
||||||
|
int? _draftVersion;
|
||||||
|
|
||||||
|
/// 恢复来的草稿既有媒体(uploader 只持本地选图,服务端媒体只读呈现)。
|
||||||
|
List<PostMediaItem> _draftMedia = const [];
|
||||||
|
|
||||||
|
/// 「已恢复上次草稿」提示条可见性。
|
||||||
|
bool _restoredBannerVisible = false;
|
||||||
|
|
||||||
|
/// 草稿恢复期间的输入监听抑制(恢复不是「首次输入」,不发 started)。
|
||||||
|
bool _restoring = false;
|
||||||
|
|
||||||
|
/// 本页是否基于先前保存/恢复的草稿发布(`fromDraft` 口径)。
|
||||||
|
bool _fromDraft = false;
|
||||||
|
|
||||||
|
/// 上一次同步到服务端的 ready assetId 签名(media 三态判定:
|
||||||
|
/// 与当前一致即 PATCH 缺席不动,不一致才整组替换)。
|
||||||
|
String? _syncedMediaSignature;
|
||||||
|
|
||||||
|
/// 建草稿幂等键(同键重放;表单改动即置 null 换新键)。
|
||||||
|
String? _idempotencyKey;
|
||||||
|
|
||||||
|
bool _publishing = false;
|
||||||
|
bool _savingDraft = false;
|
||||||
|
|
||||||
|
/// 发布失败横幅(页内停留供对照,不用 SnackBar)。
|
||||||
|
String? _publishErrorMessage;
|
||||||
|
|
||||||
|
/// 「草稿已保存」的伴随提示(发布失败时告知内容未丢)。
|
||||||
|
bool _draftPreservedHint = false;
|
||||||
|
|
||||||
|
/// 「已保存草稿 ✓」提示(保存动作后显示)。
|
||||||
|
bool _draftSavedHint = false;
|
||||||
|
|
||||||
|
/// 首次输入已上报 started。
|
||||||
|
bool _started = false;
|
||||||
|
DateTime? _startedAt;
|
||||||
|
|
||||||
|
/// 本页发布尝试序号(attemptSeq,从 1 起)。
|
||||||
|
int _publishAttemptSeq = 0;
|
||||||
|
|
||||||
|
CommunityController get _controller => widget.controller;
|
||||||
|
|
||||||
|
DateTime _nowValue() => (widget.now ?? DateTime.now)();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_uploader = (widget.uploaderFactory ?? _defaultUploaderFactory)(
|
||||||
|
_controller.repository,
|
||||||
|
widget.analytics,
|
||||||
|
);
|
||||||
|
_uploader.addListener(_onUploaderChanged);
|
||||||
|
_contentController.addListener(_onContentChanged);
|
||||||
|
unawaited(_restoreLatestDraft());
|
||||||
|
}
|
||||||
|
|
||||||
|
static MediaUploader _defaultUploaderFactory(
|
||||||
|
CommunityRepository repository,
|
||||||
|
PostAnalytics? analytics,
|
||||||
|
) => MediaUploader(repository: repository, analytics: analytics);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_uploader.removeListener(_onUploaderChanged);
|
||||||
|
// 在途上传作废(未 confirm 的 asset 弃引用,服务端超时清理兜底)。
|
||||||
|
_uploader.reset();
|
||||||
|
_uploader.dispose();
|
||||||
|
_contentController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 输入与状态 ----
|
||||||
|
|
||||||
|
String get _content => _contentController.text.trim();
|
||||||
|
|
||||||
|
bool get _isEmptyForm =>
|
||||||
|
_content.isEmpty && _uploader.isEmpty && _draftMedia.isEmpty;
|
||||||
|
|
||||||
|
/// 发布 gating(05 §2.2/§2.3 + 后端 content 必填):正文非空、
|
||||||
|
/// 在场媒体全部 ready、无在途提交。
|
||||||
|
bool get _canPublish =>
|
||||||
|
_content.isNotEmpty &&
|
||||||
|
(_uploader.isEmpty || _uploader.allReady) &&
|
||||||
|
!_publishing &&
|
||||||
|
!_savingDraft;
|
||||||
|
|
||||||
|
void _onContentChanged() {
|
||||||
|
if (_restoring) return;
|
||||||
|
_markDirty();
|
||||||
|
_reportStartedOnce();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onUploaderChanged() {
|
||||||
|
if (_uploader.items.isNotEmpty) _reportStartedOnce();
|
||||||
|
_markDirty();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 表单一经改动即弃用旧幂等键(下次提交换新键,杜绝 40905 常态化)。
|
||||||
|
void _markDirty() {
|
||||||
|
_idempotencyKey = null;
|
||||||
|
_draftSavedHint = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reportStartedOnce() {
|
||||||
|
if (_started) return;
|
||||||
|
if (_content.isEmpty && _uploader.isEmpty) return;
|
||||||
|
_started = true;
|
||||||
|
_startedAt = _nowValue();
|
||||||
|
widget.analytics?.postCreateStarted(entryPoint: widget.entryPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 草稿恢复(最小实现:最新一条)----
|
||||||
|
|
||||||
|
Future<void> _restoreLatestDraft() async {
|
||||||
|
try {
|
||||||
|
final page = await _controller.repository.listMyPosts(
|
||||||
|
limit: 1,
|
||||||
|
status: PostStatus.draft,
|
||||||
|
);
|
||||||
|
if (!mounted || page.items.isEmpty) return;
|
||||||
|
final draft = page.items.first;
|
||||||
|
_restoring = true;
|
||||||
|
_contentController.text = draft.content;
|
||||||
|
_restoring = false;
|
||||||
|
setState(() {
|
||||||
|
_draftPostId = draft.id;
|
||||||
|
_draftVersion = draft.version;
|
||||||
|
_draftMedia = draft.media;
|
||||||
|
// ai_creation(M4 预留读侧值)不在发布页可选集内,回落 general。
|
||||||
|
_category = draft.category == PostCategory.aiCreation
|
||||||
|
? PostCategory.general
|
||||||
|
: draft.category;
|
||||||
|
_restoredBannerVisible = true;
|
||||||
|
_fromDraft = true;
|
||||||
|
_syncedMediaSignature = _mediaSignature();
|
||||||
|
});
|
||||||
|
} on ApiException {
|
||||||
|
// 草稿恢复失败静默降级为「新建」,不阻塞发布(不打扰)。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearRestoredDraft() {
|
||||||
|
_restoring = true;
|
||||||
|
_contentController.clear();
|
||||||
|
_restoring = false;
|
||||||
|
setState(() {
|
||||||
|
_draftMedia = const [];
|
||||||
|
_restoredBannerVisible = false;
|
||||||
|
_idempotencyKey = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 媒体 ----
|
||||||
|
|
||||||
|
Future<void> _pickImages() async {
|
||||||
|
await _uploader.pickAndAdd();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_uploader.remainingSlots <= 0) {
|
||||||
|
_showSnackBar('最多可选 ${_uploader.maxImages} 张图片');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _mediaSignature() => _uploader.items
|
||||||
|
.where((item) => item.isReady)
|
||||||
|
.map((item) => item.assetId)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
/// 当前选图的挂接请求(全 ready 才可取;封面取首张)。
|
||||||
|
List<PostMediaAttachRequest>? _mediaAttachOrNull() =>
|
||||||
|
_uploader.isEmpty ? null : _uploader.buildAttachRequests();
|
||||||
|
|
||||||
|
// ---- 发布(建草稿 → 迁移发布)----
|
||||||
|
|
||||||
|
Future<void> _publish() async {
|
||||||
|
if (!_canPublish) return;
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
_publishAttemptSeq += 1;
|
||||||
|
setState(() {
|
||||||
|
_publishing = true;
|
||||||
|
_publishErrorMessage = null;
|
||||||
|
_draftPreservedHint = false;
|
||||||
|
});
|
||||||
|
final signature = _mediaSignature();
|
||||||
|
final fromDraft = _fromDraft && _draftPostId != null;
|
||||||
|
try {
|
||||||
|
if (_draftPostId == null) {
|
||||||
|
final key = _idempotencyKey ??= _uuid.v4();
|
||||||
|
final draft = await _controller.repository.createPost(
|
||||||
|
CreatePostRequest(
|
||||||
|
content: _content,
|
||||||
|
category: _category,
|
||||||
|
status: PostStatus.draft,
|
||||||
|
media: _mediaAttachOrNull(),
|
||||||
|
),
|
||||||
|
idempotencyKey: key,
|
||||||
|
);
|
||||||
|
_draftPostId = draft.id;
|
||||||
|
_draftVersion = draft.version;
|
||||||
|
_syncedMediaSignature = signature;
|
||||||
|
}
|
||||||
|
await _patchPublish(signature);
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.analytics?.postPublishSucceeded(
|
||||||
|
durationMs: _elapsedSinceStart(),
|
||||||
|
mediaCount: _uploader.items.length + _keptDraftMediaCount(signature),
|
||||||
|
// 话题(TopicChip / 话题选择 sheet)无契约端点,M3 恒 0。
|
||||||
|
topicCount: 0,
|
||||||
|
textLength: _content.length,
|
||||||
|
fromDraft: fromDraft,
|
||||||
|
);
|
||||||
|
_uploader.reset();
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_handlePublishError(error);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _publishing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH 迁移发布;乐观锁过期(40902,别处改过草稿)自动刷新 version
|
||||||
|
/// 重提一次。已发布帖重复提交为幂等 no-op(15 号 §2.4),弱网重放安全。
|
||||||
|
Future<void> _patchPublish(String signature) async {
|
||||||
|
final media = signature == _syncedMediaSignature
|
||||||
|
? null // 缺席不动(服务端媒体与本地选图一致)
|
||||||
|
: _mediaAttachOrNull() ?? const <PostMediaAttachRequest>[];
|
||||||
|
UpdatePostRequest request(int version) => UpdatePostRequest(
|
||||||
|
version: version,
|
||||||
|
content: _content,
|
||||||
|
category: _category,
|
||||||
|
publish: true,
|
||||||
|
media: media,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await _controller.repository.updatePost(
|
||||||
|
_draftPostId!,
|
||||||
|
request(_draftVersion!),
|
||||||
|
);
|
||||||
|
} on PostVersionConflictException {
|
||||||
|
final latest = await _controller.repository.getPost(_draftPostId!);
|
||||||
|
_draftVersion = latest.version;
|
||||||
|
await _controller.repository.updatePost(
|
||||||
|
_draftPostId!,
|
||||||
|
request(latest.version),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_syncedMediaSignature = signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 恢复草稿的服务端既有媒体在本次发布中被保留的张数(mediaCount 口径)。
|
||||||
|
int _keptDraftMediaCount(String signature) =>
|
||||||
|
signature == _syncedMediaSignature && _uploader.isEmpty
|
||||||
|
? _draftMedia.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
int _elapsedSinceStart() {
|
||||||
|
final startedAt = _startedAt;
|
||||||
|
if (startedAt == null) return 0;
|
||||||
|
return _nowValue().difference(startedAt).inMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handlePublishError(ApiException error) {
|
||||||
|
final reason = postPublishFailureReasonOf(error);
|
||||||
|
if (reason != null) {
|
||||||
|
widget.analytics?.postPublishFailed(
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: _publishAttemptSeq,
|
||||||
|
errorCode: error is ApiBusinessException ? error.code : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error is IdempotencyMismatchException) {
|
||||||
|
// 同键异 payload:弃用旧键,下次提交换新键即可成功。
|
||||||
|
_idempotencyKey = null;
|
||||||
|
}
|
||||||
|
if (error is PostNotFoundException) {
|
||||||
|
// 草稿在别处被删:解除关联,重试走全新建草稿。
|
||||||
|
_draftPostId = null;
|
||||||
|
_draftVersion = null;
|
||||||
|
_fromDraft = false;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_publishErrorMessage = postPublishErrorMessage(error);
|
||||||
|
// 草稿已在服务端 → 明确告知内容未丢(本单核心提示语义)。
|
||||||
|
_draftPreservedHint = _draftPostId != null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 存草稿 ----
|
||||||
|
|
||||||
|
Future<bool> _saveDraft(DraftSaveTrigger trigger) async {
|
||||||
|
if (_isEmptyForm) return true;
|
||||||
|
if (_content.isEmpty) {
|
||||||
|
_showSnackBar('请先写点什么再保存草稿');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_uploader.hasBusyItem) {
|
||||||
|
_showSnackBar('图片还在上传中,请稍候再保存草稿');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_uploader.hasFailure) {
|
||||||
|
_showSnackBar('有图片上传失败,请重试或删除后再保存草稿');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_savingDraft = true;
|
||||||
|
_publishErrorMessage = null;
|
||||||
|
});
|
||||||
|
final signature = _mediaSignature();
|
||||||
|
try {
|
||||||
|
if (_draftPostId == null) {
|
||||||
|
final key = _idempotencyKey ??= _uuid.v4();
|
||||||
|
final draft = await _controller.repository.createPost(
|
||||||
|
CreatePostRequest(
|
||||||
|
content: _content,
|
||||||
|
category: _category,
|
||||||
|
status: PostStatus.draft,
|
||||||
|
media: _mediaAttachOrNull(),
|
||||||
|
),
|
||||||
|
idempotencyKey: key,
|
||||||
|
);
|
||||||
|
_draftPostId = draft.id;
|
||||||
|
_draftVersion = draft.version;
|
||||||
|
} else {
|
||||||
|
final updated = await _controller.repository.updatePost(
|
||||||
|
_draftPostId!,
|
||||||
|
UpdatePostRequest(
|
||||||
|
version: _draftVersion!,
|
||||||
|
content: _content,
|
||||||
|
category: _category,
|
||||||
|
media: signature == _syncedMediaSignature
|
||||||
|
? null
|
||||||
|
: _mediaAttachOrNull() ?? const <PostMediaAttachRequest>[],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_draftVersion = updated.version;
|
||||||
|
}
|
||||||
|
_syncedMediaSignature = signature;
|
||||||
|
_fromDraft = true;
|
||||||
|
widget.analytics?.postDraftSaved(
|
||||||
|
trigger: trigger,
|
||||||
|
mediaCount: _uploader.items.length + _keptDraftMediaCount(signature),
|
||||||
|
);
|
||||||
|
if (mounted) setState(() => _draftSavedHint = true);
|
||||||
|
return true;
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (mounted) _showSnackBar(draftSaveErrorMessage(error));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _savingDraft = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「不保留」:已落服务端的草稿一并软删(`post_deleted` 触点)。
|
||||||
|
Future<void> _discardDraft() async {
|
||||||
|
final draftId = _draftPostId;
|
||||||
|
if (draftId == null) return;
|
||||||
|
try {
|
||||||
|
await _controller.repository.deletePost(draftId);
|
||||||
|
widget.analytics?.postDeleted();
|
||||||
|
} on ApiException {
|
||||||
|
// 删除失败不拦住离页(草稿留在服务端,下次进页可恢复)。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 离页 ----
|
||||||
|
|
||||||
|
Future<void> _onCancel() async {
|
||||||
|
if (_publishing || _savingDraft) return;
|
||||||
|
if (_isEmptyForm) {
|
||||||
|
Navigator.of(context).pop(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final choice = await showDialog<_ExitChoice>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('保留草稿?'),
|
||||||
|
content: const Text('保留后下次进入发布页可继续编辑。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, _ExitChoice.keepEditing),
|
||||||
|
child: const Text('继续编辑'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, _ExitChoice.discard),
|
||||||
|
child: const Text('不保留'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, _ExitChoice.keep),
|
||||||
|
child: const Text('保留'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted || choice == null || choice == _ExitChoice.keepEditing) return;
|
||||||
|
if (choice == _ExitChoice.discard) {
|
||||||
|
await _discardDraft();
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pop(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final saved = await _saveDraft(DraftSaveTrigger.onExit);
|
||||||
|
if (!mounted || !saved) return;
|
||||||
|
Navigator.of(context).pop(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 渲染 ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return PopScope(
|
||||||
|
canPop: _isEmptyForm && !_publishing && !_savingDraft,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (!didPop) unawaited(_onCancel());
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leadingWidth: 76,
|
||||||
|
leading: Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _publishing ? null : () => unawaited(_onCancel()),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.ink),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: const Text('发布动态'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _publishing || _savingDraft
|
||||||
|
? null
|
||||||
|
: () => unawaited(_saveDraft(DraftSaveTrigger.manual)),
|
||||||
|
child: const Text('存草稿'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 40),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
),
|
||||||
|
onPressed: _canPublish ? () => unawaited(_publish()) : null,
|
||||||
|
child: _publishing
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('发布'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||||
|
children: [
|
||||||
|
if (_restoredBannerVisible) ...[
|
||||||
|
_RestoredDraftBanner(onClear: _clearRestoredDraft),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (_publishErrorMessage != null) ...[
|
||||||
|
InlineErrorBanner(message: _publishErrorMessage!),
|
||||||
|
if (_draftPreservedHint) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
const Text(
|
||||||
|
'草稿已保存,可稍后继续发布',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.inkSoft),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (_draftSavedHint) ...[
|
||||||
|
const Text(
|
||||||
|
'已保存草稿 ✓',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.inkSoft),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
..._mediaSection(),
|
||||||
|
if (_uploader.hasBusyItem) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_UploadSummaryBar(
|
||||||
|
progress: _uploader.overallProgress,
|
||||||
|
readyCount: _uploader.readyCount,
|
||||||
|
total: _uploader.items.length,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _contentController,
|
||||||
|
minLines: 6,
|
||||||
|
maxLines: null,
|
||||||
|
maxLength: _maxContentLength,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '分享毛孩子的日常,或向宠友求助…',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('分类', style: theme.textTheme.bodySmall),
|
||||||
|
const SizedBox(height: 7),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final entry in const [
|
||||||
|
(PostCategory.general, '日常分享'),
|
||||||
|
(PostCategory.help, '求助'),
|
||||||
|
])
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(entry.$2),
|
||||||
|
selected: _category == entry.$1,
|
||||||
|
onSelected: (_) {
|
||||||
|
_markDirty();
|
||||||
|
setState(() => _category = entry.$1);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.location_on_outlined),
|
||||||
|
title: const Text('添加位置(选填)'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _showSnackBar('位置功能即将上线'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _mediaSection() {
|
||||||
|
final showDraftMedia = _uploader.isEmpty && _draftMedia.isNotEmpty;
|
||||||
|
return [
|
||||||
|
PostMediaEditGrid(
|
||||||
|
items: _uploader.items,
|
||||||
|
canAdd: _uploader.remainingSlots > 0,
|
||||||
|
onAdd: _uploader.isPicking ? null : () => unawaited(_pickImages()),
|
||||||
|
onRemove: _uploader.remove,
|
||||||
|
onRetry: _uploader.retry,
|
||||||
|
),
|
||||||
|
if (showDraftMedia) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'草稿已含 ${_draftMedia.length} 张图片(发布时保留;重新选图将整组替换)',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.inkSoft),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _ExitChoice { keep, discard, keepEditing }
|
||||||
|
|
||||||
|
/// 「已恢复上次草稿」提示条(05 §2.3:surfaceTint 底、圆角 sm12)。
|
||||||
|
class _RestoredDraftBanner extends StatelessWidget {
|
||||||
|
const _RestoredDraftBanner({required this.onClear});
|
||||||
|
|
||||||
|
final VoidCallback onClear;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 4, 4, 4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.surfaceTint,
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(AppRadius.sm)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'已恢复上次草稿',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.primaryDark),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: onClear, child: const Text('清空')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 页级上传汇总条(05 §3.3 末段:线性进度 + 「正在上传 n/N」)。
|
||||||
|
class _UploadSummaryBar extends StatelessWidget {
|
||||||
|
const _UploadSummaryBar({
|
||||||
|
required this.progress,
|
||||||
|
required this.readyCount,
|
||||||
|
required this.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double progress;
|
||||||
|
final int readyCount;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'正在上传 $readyCount/$total',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.inkSoft),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
minHeight: 4,
|
||||||
|
color: AppColors.primaryStrong,
|
||||||
|
backgroundColor: AppColors.surfaceTint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 服务端返回的开关权威终态(LikeState / BookmarkState 的统一投影)。
|
||||||
|
class ToggleOutcome {
|
||||||
|
const ToggleOutcome({required this.active, required this.count});
|
||||||
|
|
||||||
|
final bool active;
|
||||||
|
final int count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内存副本当前读数(乐观翻转与回滚校验的基准)。
|
||||||
|
class ToggleReading {
|
||||||
|
const ToggleReading({required this.active, required this.count});
|
||||||
|
|
||||||
|
final bool active;
|
||||||
|
final int count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞 / 收藏共用的乐观更新小状态机(03 号评估 §3 定稿的数据层部分):
|
||||||
|
/// **乐观翻转 + 快照回滚 + 单飞合并意图 + 代次守卫**。字段读写与端点
|
||||||
|
/// 全部参数化,like / bookmark 各持一实例,不复制两份逻辑。
|
||||||
|
///
|
||||||
|
/// 对每个 id 的一轮「操作链」:
|
||||||
|
/// 1. 点击立即经 [write] 翻转内存副本(UI 同帧反馈由持有方 notify);
|
||||||
|
/// 2. 非在途则记快照、发请求(PUT/DELETE 语义幂等,重放安全);
|
||||||
|
/// 在途则只把新意图并入 pendingTarget,**不发新请求**(单飞);
|
||||||
|
/// 3. 成功:pendingTarget 与已确认态不一致 → 以 pendingTarget 补发一次
|
||||||
|
/// (连续快速点击至多两个在途请求,中间抖动全被合并);一致 → 用服务端
|
||||||
|
/// 权威计数覆盖乐观计数(吸收他人并发造成的偏差),清状态;
|
||||||
|
/// 4. 失败:恢复链起点快照(先校验 id 仍可读且当前态仍是本轮乐观目标,
|
||||||
|
/// 避免覆盖新数据),经 [onError] 轻提示,**不自动重试**;
|
||||||
|
/// 5. 代次守卫:响应到达时 [generation] 与链起点不符(期间发生过刷新,
|
||||||
|
/// 列表已被服务端数据整体替换)→ 丢弃该响应,不覆盖不回滚。
|
||||||
|
class ToggleSync {
|
||||||
|
ToggleSync({
|
||||||
|
required this._read,
|
||||||
|
required this._write,
|
||||||
|
required this._send,
|
||||||
|
required this._generation,
|
||||||
|
this._onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ToggleReading? Function(String id) _read;
|
||||||
|
final void Function(String id, bool active, int count) _write;
|
||||||
|
final Future<ToggleOutcome> Function(String id, bool target) _send;
|
||||||
|
final int Function() _generation;
|
||||||
|
final void Function(String id, Object error)? _onError;
|
||||||
|
|
||||||
|
final Map<String, _ToggleChain> _chains = {};
|
||||||
|
|
||||||
|
/// 该 id 是否有请求在途(测试与调试观测口)。
|
||||||
|
bool isInFlight(String id) => _chains.containsKey(id);
|
||||||
|
|
||||||
|
/// 翻转一次。同步完成乐观写入;网络往返在后台收敛,不外抛。
|
||||||
|
void toggle(String id) {
|
||||||
|
final current = _read(id);
|
||||||
|
if (current == null) return; // 已不在列表(刷新剔除),本次点击作废。
|
||||||
|
final target = !current.active;
|
||||||
|
final optimisticCount = target
|
||||||
|
? current.count + 1
|
||||||
|
: (current.count - 1 < 0 ? 0 : current.count - 1);
|
||||||
|
_write(id, target, optimisticCount);
|
||||||
|
|
||||||
|
final chain = _chains[id];
|
||||||
|
if (chain != null) {
|
||||||
|
chain.pendingTarget = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final started = _ToggleChain(
|
||||||
|
snapshot: current,
|
||||||
|
generation: _generation(),
|
||||||
|
target: target,
|
||||||
|
);
|
||||||
|
_chains[id] = started;
|
||||||
|
unawaited(_run(id, started));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登出 / 整体刷新清态:丢弃全部链(在途响应因代次或链失配被丢弃)。
|
||||||
|
void reset() => _chains.clear();
|
||||||
|
|
||||||
|
Future<void> _run(String id, _ToggleChain chain) async {
|
||||||
|
while (true) {
|
||||||
|
ToggleOutcome outcome;
|
||||||
|
try {
|
||||||
|
outcome = await _send(id, chain.target);
|
||||||
|
} catch (error) {
|
||||||
|
if (_chains[id] == chain && _generation() == chain.generation) {
|
||||||
|
final current = _read(id);
|
||||||
|
final lastTarget = chain.pendingTarget ?? chain.target;
|
||||||
|
// 回滚前校验:id 仍可读且当前态仍是本轮乐观写入的目标态。
|
||||||
|
if (current != null && current.active == lastTarget) {
|
||||||
|
_write(id, chain.snapshot.active, chain.snapshot.count);
|
||||||
|
}
|
||||||
|
_onError?.call(id, error);
|
||||||
|
}
|
||||||
|
_release(id, chain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_chains[id] != chain || _generation() != chain.generation) {
|
||||||
|
_release(id, chain);
|
||||||
|
return; // 代次不符 / 已被 reset:丢弃响应,不覆盖不回滚。
|
||||||
|
}
|
||||||
|
|
||||||
|
final pending = chain.pendingTarget;
|
||||||
|
if (pending != null && pending != outcome.active) {
|
||||||
|
chain.target = pending;
|
||||||
|
chain.pendingTarget = null;
|
||||||
|
continue; // 以最终意图补发一次。
|
||||||
|
}
|
||||||
|
|
||||||
|
_write(id, outcome.active, outcome.count);
|
||||||
|
_release(id, chain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _release(String id, _ToggleChain chain) {
|
||||||
|
if (_chains[id] == chain) _chains.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一轮操作链的在途状态。
|
||||||
|
class _ToggleChain {
|
||||||
|
_ToggleChain({
|
||||||
|
required this.snapshot,
|
||||||
|
required this.generation,
|
||||||
|
required this.target,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 链起点快照(回滚基准)。
|
||||||
|
final ToggleReading snapshot;
|
||||||
|
|
||||||
|
/// 链起点的刷新代次。
|
||||||
|
final int generation;
|
||||||
|
|
||||||
|
/// 当前在途请求的目标态。
|
||||||
|
bool target;
|
||||||
|
|
||||||
|
/// 在途期间用户新点出的最终意图(完成后据此决定是否补发)。
|
||||||
|
bool? pendingTarget;
|
||||||
|
}
|
||||||
@@ -7,15 +7,23 @@ import 'package:patbond_flutter/widgets/common.dart';
|
|||||||
|
|
||||||
enum CreationMode { image, video }
|
enum CreationMode { image, video }
|
||||||
|
|
||||||
|
/// 创作 Tab。
|
||||||
|
///
|
||||||
|
/// **AI 生成模拟(700/650/500ms 假延时、风格/模型/分辨率设置、结果卡)
|
||||||
|
/// 属 M4 范围,T3-17 原样保留**;社区发布半边自 T3-17 起改由真实发布页
|
||||||
|
/// (`PostComposePage`,push 全屏)承担——本页顶部「发布动态」入口即其
|
||||||
|
/// 入口(entryPoint=create_tab),`AppState.publishPost` demo 发布流退役。
|
||||||
class CreatePage extends StatefulWidget {
|
class CreatePage extends StatefulWidget {
|
||||||
const CreatePage({
|
const CreatePage({
|
||||||
required this.appState,
|
required this.appState,
|
||||||
required this.onPublished,
|
required this.onOpenCompose,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppState appState;
|
final AppState appState;
|
||||||
final ValueChanged<PostModel> onPublished;
|
|
||||||
|
/// 真实发布页入口(主壳 push,发布成功后回首页 Feed 刷新)。
|
||||||
|
final VoidCallback onOpenCompose;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CreatePage> createState() => _CreatePageState();
|
State<CreatePage> createState() => _CreatePageState();
|
||||||
@@ -111,36 +119,13 @@ class _CreatePageState extends State<CreatePage> {
|
|||||||
setState(() => tags = [...tags, value]);
|
setState(() => tags = [...tags, value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 作品的社区发布留待 M4:AI 结果是生成图(无本地文件、无 media
|
||||||
|
/// asset),走不了两步上传,故不接真实发布链路;demo 发布流(写
|
||||||
|
/// `AppState.posts`)随 T3-17 退役,此处只余占位提示。
|
||||||
void publish() {
|
void publish() {
|
||||||
if (resultUrl == null || titleController.text.trim().isEmpty) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
ScaffoldMessenger.of(
|
const SnackBar(content: Text('AI 作品发布随 AI 创作能力上线(M4);发布普通动态请用上方「发布动态」')),
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('请先完成生成并填写标题')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final post = PostModel(
|
|
||||||
id: 'post_user_${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
authorName: '萌宠新手(我)',
|
|
||||||
authorAvatar: userAvatar,
|
|
||||||
time: '刚刚',
|
|
||||||
breedTag: 'AI创作',
|
|
||||||
content:
|
|
||||||
'${titleController.text.trim()}\n\n${contentController.text.trim()}',
|
|
||||||
mainImage: resultUrl!,
|
|
||||||
likes: 1,
|
|
||||||
tags: [...tags, mode == CreationMode.image ? 'AI生图' : 'AI视频'],
|
|
||||||
comments: const [],
|
|
||||||
hasLiked: true,
|
|
||||||
);
|
);
|
||||||
widget.appState.publishPost(post);
|
|
||||||
setState(() {
|
|
||||||
uploaded = false;
|
|
||||||
resultUrl = null;
|
|
||||||
generationStep = 0;
|
|
||||||
titleController.clear();
|
|
||||||
contentController.clear();
|
|
||||||
});
|
|
||||||
widget.onPublished(post);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -148,6 +133,8 @@ class _CreatePageState extends State<CreatePage> {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||||
children: [
|
children: [
|
||||||
|
_ComposeEntryCard(onTap: widget.onOpenCompose),
|
||||||
|
const SizedBox(height: 18),
|
||||||
SegmentedButton<CreationMode>(
|
SegmentedButton<CreationMode>(
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
@@ -402,6 +389,35 @@ class _CreatePageState extends State<CreatePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 真实发布入口(T3-17):本页其余部分是 AI 创作模拟(M4),社区发帖
|
||||||
|
/// 走这里 push 的发布页。
|
||||||
|
class _ComposeEntryCard extends StatelessWidget {
|
||||||
|
const _ComposeEntryCard({required this.onTap});
|
||||||
|
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SectionCard(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
backgroundColor: AppColors.surfaceTint,
|
||||||
|
child: Icon(Icons.edit_outlined, color: AppColors.primary),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'发布动态',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
subtitle: const Text('写点文字、配上照片,分享给宠友'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _UploadCard extends StatelessWidget {
|
class _UploadCard extends StatelessWidget {
|
||||||
const _UploadCard({
|
const _UploadCard({
|
||||||
required this.uploaded,
|
required this.uploaded,
|
||||||
|
|||||||
+384
-134
@@ -1,42 +1,252 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/feed_skeleton.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_card.dart';
|
||||||
import 'package:patbond_flutter/data/demo_data.dart';
|
import 'package:patbond_flutter/data/demo_data.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/feed_exposure.dart';
|
||||||
import 'package:patbond_flutter/models/models.dart';
|
import 'package:patbond_flutter/models/models.dart';
|
||||||
import 'package:patbond_flutter/state/app_state.dart';
|
import 'package:patbond_flutter/state/app_state.dart';
|
||||||
import 'package:patbond_flutter/widgets/common.dart';
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
enum HomeSegment { feed, services }
|
enum HomeSegment { feed, services }
|
||||||
|
|
||||||
|
/// 首页 Tab:Feed 段自 T3-14 起消费 [CommunityController] 真实数据
|
||||||
|
/// (四态 + 游标翻页 + 聚合曝光埋点);天气条/问候卡/搜索/服务段与
|
||||||
|
/// `_StoryRow` 家具保留 demo 形态(03 号评估 §1.1 判定)。
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
const HomePage({
|
const HomePage({
|
||||||
required this.appState,
|
required this.appState,
|
||||||
required this.onOpenPost,
|
required this.communityController,
|
||||||
required this.onOpenServices,
|
required this.onOpenServices,
|
||||||
required this.onOpenCreate,
|
required this.onOpenCompose,
|
||||||
super.key,
|
super.key,
|
||||||
|
this.onOpenPost,
|
||||||
|
this.feedAnalytics,
|
||||||
|
this.isActive = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppState appState;
|
final AppState appState;
|
||||||
final ValueChanged<PostModel> onOpenPost;
|
|
||||||
|
/// Feed 数据源(Tab 级单例,app.dart 装配注入)。
|
||||||
|
final CommunityController communityController;
|
||||||
|
|
||||||
final ValueChanged<bool> onOpenServices;
|
final ValueChanged<bool> onOpenServices;
|
||||||
final VoidCallback onOpenCreate;
|
|
||||||
|
/// 发布页入口(T3-17:story 环「发布」与空态 CTA 均 push 真实发布页,
|
||||||
|
/// entryPoint=feed;创作 Tab 的 AI 模拟不再是社区发帖入口)。
|
||||||
|
final VoidCallback onOpenCompose;
|
||||||
|
|
||||||
|
/// 帖子详情导航(T3-15 接通;主壳 push PostDetailPage)。
|
||||||
|
final ValueChanged<String>? onOpenPost;
|
||||||
|
|
||||||
|
/// feed 域埋点(feed_viewed 聚合曝光 + feed_load_failed)。
|
||||||
|
final FeedAnalytics? feedAnalytics;
|
||||||
|
|
||||||
|
/// 首页 Tab 是否为当前可见 Tab(IndexedStack 各 Tab 常驻构建,
|
||||||
|
/// 由主壳传入以驱动浏览段开/结算)。
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HomePage> createState() => _HomePageState();
|
State<HomePage> createState() => _HomePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||||
HomeSegment segment = HomeSegment.feed;
|
HomeSegment segment = HomeSegment.feed;
|
||||||
String query = '';
|
String query = '';
|
||||||
String sort = '综合';
|
String sort = '综合';
|
||||||
|
|
||||||
List<PostModel> get filteredPosts {
|
/// 当前 Feed 浏览段(不在 Feed 面上时为 null)。
|
||||||
|
FeedViewSegment? _viewSegment;
|
||||||
|
|
||||||
|
/// 曝光扫描用:postId → 卡片 GlobalKey。
|
||||||
|
final Map<String, GlobalKey> _cardKeys = {};
|
||||||
|
final GlobalKey _listKey = GlobalKey();
|
||||||
|
|
||||||
|
AppLifecycleState _lastLifecycle = AppLifecycleState.resumed;
|
||||||
|
|
||||||
|
CommunityController get _feed => widget.communityController;
|
||||||
|
|
||||||
|
bool get _feedSurfaceVisible =>
|
||||||
|
widget.isActive && segment == HomeSegment.feed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
// 主壳挂载即预取(pets 先例);重登后控制器已 reset 回 initial。
|
||||||
|
// 首屏自动预取不计入浏览段 refreshCount(非用户动作)。
|
||||||
|
if (_feed.phase == FeedPhase.initial) {
|
||||||
|
_refreshFeed(userInitiated: false);
|
||||||
|
}
|
||||||
|
if (_feedSurfaceVisible) _startSegment();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(HomePage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.isActive != widget.isActive) {
|
||||||
|
widget.isActive ? _maybeStartSegment() : _settleSegment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// 退后台即结算浏览段(06 §1.2:feed_viewed 触发点之一);只在离开
|
||||||
|
// resumed 的第一次变更结算(inactive/hidden/paused 级联不重复)。
|
||||||
|
// 回前台若仍在 Feed 面上则开新段。
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_lastLifecycle = state;
|
||||||
|
_maybeStartSegment();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_lastLifecycle == AppLifecycleState.resumed) {
|
||||||
|
_settleSegment();
|
||||||
|
}
|
||||||
|
_lastLifecycle = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_settleSegment();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 浏览段开/结算 ----
|
||||||
|
|
||||||
|
void _startSegment() {
|
||||||
|
_viewSegment ??= FeedViewSegment();
|
||||||
|
_scheduleVisibilityScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _maybeStartSegment() {
|
||||||
|
if (_feedSurfaceVisible && _lastLifecycle == AppLifecycleState.resumed) {
|
||||||
|
_startSegment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _settleSegment() {
|
||||||
|
final summary = _viewSegment?.settle();
|
||||||
|
_viewSegment = null;
|
||||||
|
if (summary != null && widget.feedAnalytics != null) {
|
||||||
|
summary.report(widget.feedAnalytics!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSegmentChanged(HomeSegment value) {
|
||||||
|
if (value == segment) return;
|
||||||
|
setState(() => segment = value);
|
||||||
|
value == HomeSegment.feed ? _maybeStartSegment() : _settleSegment();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 曝光可见性扫描 ----
|
||||||
|
|
||||||
|
bool _scanScheduled = false;
|
||||||
|
|
||||||
|
/// 帧末扫描(滚动通知发生在本帧布局前,同步读 RenderBox 是旧位置;
|
||||||
|
/// 统一调度到 post-frame,一帧至多一次)。
|
||||||
|
void _scheduleVisibilityScan() {
|
||||||
|
if (_scanScheduled) return;
|
||||||
|
_scanScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_scanScheduled = false;
|
||||||
|
_scanVisibility();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 以列表视口与各卡片 RenderBox 的纵向交叠比例回报可见性
|
||||||
|
/// (≥50% 且驻留 ≥500ms 才计曝光,判定在 [FeedViewSegment] 内)。
|
||||||
|
void _scanVisibility() {
|
||||||
|
final viewSegment = _viewSegment;
|
||||||
|
if (viewSegment == null || !mounted) return;
|
||||||
|
final listBox = _listKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (listBox == null || !listBox.attached) return;
|
||||||
|
final viewportTop = listBox.localToGlobal(Offset.zero).dy;
|
||||||
|
final viewportBottom = viewportTop + listBox.size.height;
|
||||||
|
for (final entry in _cardKeys.entries) {
|
||||||
|
final box = entry.value.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
var fraction = 0.0;
|
||||||
|
if (box != null && box.attached && box.hasSize && box.size.height > 0) {
|
||||||
|
final top = box.localToGlobal(Offset.zero).dy;
|
||||||
|
final bottom = top + box.size.height;
|
||||||
|
final visible =
|
||||||
|
bottom.clamp(viewportTop, viewportBottom) -
|
||||||
|
top.clamp(viewportTop, viewportBottom);
|
||||||
|
fraction = visible / box.size.height;
|
||||||
|
}
|
||||||
|
viewSegment.updateVisibility(entry.key, fraction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _onScrollNotification(ScrollNotification notification) {
|
||||||
|
if (segment != HomeSegment.feed) return false;
|
||||||
|
if (notification is ScrollUpdateNotification ||
|
||||||
|
notification is ScrollEndNotification) {
|
||||||
|
_scheduleVisibilityScan();
|
||||||
|
// 滚动近底触发翻页(余量 400 提前预取);失败态不自动重试,
|
||||||
|
// 只走尾部重试条显式点按(避免滚动风暴反复打失败端点)。
|
||||||
|
if (notification.metrics.extentAfter < 400 &&
|
||||||
|
_feed.loadMorePhase == LoadMorePhase.idle) {
|
||||||
|
_loadMoreFeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 数据加载与失败埋点 ----
|
||||||
|
|
||||||
|
Future<void> _refreshFeed({required bool userInitiated}) async {
|
||||||
|
if (userInitiated) _viewSegment?.recordRefresh();
|
||||||
|
await _feed.refresh();
|
||||||
|
final error = _feed.refreshError ?? _feed.lastError;
|
||||||
|
if (error != null) {
|
||||||
|
widget.feedAnalytics?.feedLoadFailedFrom(
|
||||||
|
error,
|
||||||
|
feedTab: FeedTab.home,
|
||||||
|
loadType: FeedLoadType.refresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 刷新失败但旧列表被保留:SnackBar 轻提示(首屏失败走 error 态横幅)。
|
||||||
|
if (_feed.refreshError != null && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(feedLoadErrorMessage(_feed.refreshError))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMoreFeed() async {
|
||||||
|
if (_feed.phase != FeedPhase.ready ||
|
||||||
|
!_feed.hasMore ||
|
||||||
|
_feed.loadMorePhase == LoadMorePhase.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_viewSegment?.recordLoadMore();
|
||||||
|
await _feed.loadMore();
|
||||||
|
final error = _feed.loadMoreError;
|
||||||
|
if (error != null) {
|
||||||
|
widget.feedAnalytics?.feedLoadFailedFrom(
|
||||||
|
error,
|
||||||
|
feedTab: FeedTab.home,
|
||||||
|
loadType: FeedLoadType.loadMore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 搜索词过滤(demo 交互保留:只过滤已加载的多页缓存,不发检索请求;
|
||||||
|
/// 契约 v1.3.0 无搜索端点)。
|
||||||
|
List<FeedCard> get _visibleCards {
|
||||||
final keyword = query.trim().toLowerCase();
|
final keyword = query.trim().toLowerCase();
|
||||||
if (keyword.isEmpty) return widget.appState.posts;
|
if (keyword.isEmpty) return _feed.feed;
|
||||||
return widget.appState.posts.where((post) {
|
return _feed.feed.where((card) {
|
||||||
return post.content.toLowerCase().contains(keyword) ||
|
return card.contentPreview.toLowerCase().contains(keyword) ||
|
||||||
post.authorName.toLowerCase().contains(keyword) ||
|
(card.title?.toLowerCase().contains(keyword) ?? false) ||
|
||||||
post.tags.any((tag) => tag.toLowerCase().contains(keyword));
|
authorDisplayName(card.author).toLowerCase().contains(keyword);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +305,32 @@ class _HomePageState extends State<HomePage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RefreshIndicator(
|
return ListenableBuilder(
|
||||||
onRefresh: () async =>
|
listenable: _feed,
|
||||||
Future<void>.delayed(const Duration(milliseconds: 500)),
|
builder: (context, _) {
|
||||||
|
// 点赞/收藏对账失败的一次性 SnackBar(§3.5 回滚提示;与详情页
|
||||||
|
// 共用 controller 的 toggleError 消费口,先消费者清空)。
|
||||||
|
if (_feed.toggleError != null) {
|
||||||
|
_feed.clearToggleError();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('操作失败,请重试')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ready 后(含翻页追加)补一次可见性扫描:无滚动也能记首屏曝光。
|
||||||
|
if (_viewSegment != null && _feed.phase == FeedPhase.ready) {
|
||||||
|
_scheduleVisibilityScan();
|
||||||
|
}
|
||||||
|
return NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: _onScrollNotification,
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: () => segment == HomeSegment.feed
|
||||||
|
? _refreshFeed(userInitiated: true)
|
||||||
|
: Future<void>.delayed(const Duration(milliseconds: 500)),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
key: _listKey,
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||||
children: [
|
children: [
|
||||||
_WeatherStatusBar(
|
_WeatherStatusBar(
|
||||||
@@ -137,40 +369,24 @@ class _HomePageState extends State<HomePage> {
|
|||||||
],
|
],
|
||||||
selected: {segment},
|
selected: {segment},
|
||||||
showSelectedIcon: false,
|
showSelectedIcon: false,
|
||||||
onSelectionChanged: (value) {
|
onSelectionChanged: (value) => _onSegmentChanged(value.first),
|
||||||
setState(() => segment = value.first);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
if (segment == HomeSegment.feed) ...[
|
if (segment == HomeSegment.feed) ...[
|
||||||
_StoryRow(onCreate: widget.onOpenCreate),
|
_StoryRow(onCreate: widget.onOpenCompose),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
_PromoCard(onTap: () => widget.onOpenServices(true)),
|
_PromoCard(onTap: () => widget.onOpenServices(true)),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
if (filteredPosts.isEmpty)
|
..._feedSection(),
|
||||||
const EmptyState(message: '没有找到相关动态')
|
|
||||||
else
|
|
||||||
...filteredPosts.map(
|
|
||||||
(post) => Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
|
||||||
child: _PostCard(
|
|
||||||
post: post,
|
|
||||||
onTap: () => widget.onOpenPost(post),
|
|
||||||
onLike: () => widget.appState.updatePost(
|
|
||||||
post.copyWith(
|
|
||||||
hasLiked: !post.hasLiked,
|
|
||||||
likes: post.hasLiked ? post.likes - 1 : post.likes + 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else ...[
|
] else ...[
|
||||||
_CategoryGrid(onTap: (_) => widget.onOpenServices(false)),
|
_CategoryGrid(onTap: (_) => widget.onOpenServices(false)),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text('附近推荐', style: Theme.of(context).textTheme.titleLarge),
|
Text(
|
||||||
|
'附近推荐',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
for (final item in ['综合', '距离', '评分'])
|
for (final item in ['综合', '距离', '评分'])
|
||||||
Padding(
|
Padding(
|
||||||
@@ -201,7 +417,138 @@ class _HomePageState extends State<HomePage> {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Feed 段四态渲染 ----
|
||||||
|
|
||||||
|
List<Widget> _feedSection() {
|
||||||
|
switch (_feed.phase) {
|
||||||
|
case FeedPhase.initial:
|
||||||
|
case FeedPhase.loading:
|
||||||
|
return const [
|
||||||
|
FeedSkeleton(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
FeedSkeleton(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
FeedSkeleton(),
|
||||||
|
];
|
||||||
|
case FeedPhase.error:
|
||||||
|
return [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
InlineErrorBanner(
|
||||||
|
message: feedLoadErrorMessage(_feed.lastError),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => _refreshFeed(userInitiated: true),
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
case FeedPhase.ready:
|
||||||
|
if (_feed.feed.isEmpty) {
|
||||||
|
return [
|
||||||
|
EmptyStateIllustration(
|
||||||
|
icon: Icons.forum_outlined,
|
||||||
|
title: '还没有动态',
|
||||||
|
description: '关注的毛孩子们还没发帖,去逛逛话题吧',
|
||||||
|
ctaLabel: '发布第一条',
|
||||||
|
onCtaPressed: widget.onOpenCompose,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
final cards = _visibleCards;
|
||||||
|
_pruneCardKeys(cards);
|
||||||
|
if (cards.isEmpty) {
|
||||||
|
return const [EmptyState(message: '没有找到相关动态')];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final card in cards)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: KeyedSubtree(
|
||||||
|
key: _cardKeys.putIfAbsent(card.id, GlobalKey.new),
|
||||||
|
// 点赞/收藏经共享 ToggleSync(source=feed 缺省);整卡与
|
||||||
|
// 评论钮进详情(T3-15 导航接通,T3-14 的占位提示移除)。
|
||||||
|
child: PostCard(
|
||||||
|
card: card,
|
||||||
|
onTap: () => widget.onOpenPost?.call(card.id),
|
||||||
|
onCommentTap: () => widget.onOpenPost?.call(card.id),
|
||||||
|
onLikeTap: () => _feed.toggleLike(card.id),
|
||||||
|
onBookmarkTap: () => _feed.toggleBookmark(card.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 搜索过滤中不渲染尾部(过滤只作用于已加载页,翻页语义混淆)。
|
||||||
|
if (query.trim().isEmpty) _feedTail(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卡片 key 表随当前列表收敛(刷新整体替换后旧帖的驻留计时作废)。
|
||||||
|
void _pruneCardKeys(List<FeedCard> cards) {
|
||||||
|
final alive = {for (final card in cards) card.id};
|
||||||
|
_cardKeys.removeWhere((id, _) {
|
||||||
|
if (alive.contains(id)) return false;
|
||||||
|
_viewSegment?.updateVisibility(id, 0);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 尾部三态:加载中转圈 / 失败重试 / 到底「没有更多了」。
|
||||||
|
Widget _feedTail() {
|
||||||
|
switch (_feed.loadMorePhase) {
|
||||||
|
case LoadMorePhase.loading:
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case LoadMorePhase.error:
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
feedLoadErrorMessage(_feed.loadMoreError),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _loadMoreFeed,
|
||||||
|
child: const Text('加载失败,点此重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case LoadMorePhase.idle:
|
||||||
|
if (_feed.hasMore) return const SizedBox(height: 24);
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'没有更多了',
|
||||||
|
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,103 +1008,6 @@ class _PromoCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PostCard extends StatelessWidget {
|
|
||||||
const _PostCard({
|
|
||||||
required this.post,
|
|
||||||
required this.onTap,
|
|
||||||
required this.onLike,
|
|
||||||
});
|
|
||||||
|
|
||||||
final PostModel post;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
final VoidCallback onLike;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
RemoteImage(
|
|
||||||
url: post.authorAvatar,
|
|
||||||
width: 38,
|
|
||||||
height: 38,
|
|
||||||
borderRadius: BorderRadius.circular(19),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
post.authorName,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${post.time} · ${post.breedTag}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(Icons.more_horiz, color: AppColors.muted),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AspectRatio(
|
|
||||||
aspectRatio: 4 / 3,
|
|
||||||
child: RemoteImage(url: post.mainImage),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
post.content,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
ActionChip(
|
|
||||||
avatar: Icon(
|
|
||||||
post.hasLiked
|
|
||||||
? Icons.favorite
|
|
||||||
: Icons.favorite_border,
|
|
||||||
size: 17,
|
|
||||||
color: post.hasLiked ? Colors.red : AppColors.primary,
|
|
||||||
),
|
|
||||||
label: Text('${post.likes}'),
|
|
||||||
onPressed: onLike,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Chip(
|
|
||||||
avatar: const Icon(Icons.chat_bubble_outline, size: 16),
|
|
||||||
label: Text('${post.comments.length}'),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
const Icon(Icons.share_outlined, color: AppColors.muted),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CategoryGrid extends StatelessWidget {
|
class _CategoryGrid extends StatelessWidget {
|
||||||
const _CategoryGrid({required this.onTap});
|
const _CategoryGrid({required this.onTap});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.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/create/create_page.dart';
|
||||||
import 'package:patbond_flutter/features/home/home_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/health_record_analytics.dart';
|
||||||
@@ -11,7 +19,6 @@ import 'package:patbond_flutter/features/pets/pets_page.dart';
|
|||||||
import 'package:patbond_flutter/features/post/post_detail_page.dart';
|
import 'package:patbond_flutter/features/post/post_detail_page.dart';
|
||||||
import 'package:patbond_flutter/features/profile/profile_page.dart';
|
import 'package:patbond_flutter/features/profile/profile_page.dart';
|
||||||
import 'package:patbond_flutter/features/services/services_page.dart';
|
import 'package:patbond_flutter/features/services/services_page.dart';
|
||||||
import 'package:patbond_flutter/models/models.dart';
|
|
||||||
import 'package:patbond_flutter/state/app_state.dart';
|
import 'package:patbond_flutter/state/app_state.dart';
|
||||||
import 'package:patbond_flutter/widgets/common.dart';
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
@@ -19,9 +26,15 @@ class MainShellPage extends StatefulWidget {
|
|||||||
const MainShellPage({
|
const MainShellPage({
|
||||||
required this.appState,
|
required this.appState,
|
||||||
required this.petsController,
|
required this.petsController,
|
||||||
|
required this.communityController,
|
||||||
super.key,
|
super.key,
|
||||||
|
this.currentUserId,
|
||||||
this.petAnalytics,
|
this.petAnalytics,
|
||||||
this.healthRecordAnalytics,
|
this.healthRecordAnalytics,
|
||||||
|
this.feedAnalytics,
|
||||||
|
this.interactionAnalytics,
|
||||||
|
this.postAnalytics,
|
||||||
|
this.mediaUploaderFactory,
|
||||||
this.pageViewTracker,
|
this.pageViewTracker,
|
||||||
this.onLogout,
|
this.onLogout,
|
||||||
});
|
});
|
||||||
@@ -31,12 +44,30 @@ class MainShellPage extends StatefulWidget {
|
|||||||
/// 宠物档案状态(T2-11 拆出的独立 pets feature;档案 Tab 数据源)。
|
/// 宠物档案状态(T2-11 拆出的独立 pets feature;档案 Tab 数据源)。
|
||||||
final PetsController petsController;
|
final PetsController petsController;
|
||||||
|
|
||||||
|
/// 社区状态(T3-12 数据层;首页 Feed segment 数据源,T3-14 接线)。
|
||||||
|
final CommunityController communityController;
|
||||||
|
|
||||||
|
/// 当前登录用户 id(详情页评论删除入口 / 关注钮自见性的 UI 判定)。
|
||||||
|
final String? currentUserId;
|
||||||
|
|
||||||
/// pet 域埋点强类型封装(建宠漏斗三事件)。
|
/// pet 域埋点强类型封装(建宠漏斗三事件)。
|
||||||
final PetAnalytics? petAnalytics;
|
final PetAnalytics? petAnalytics;
|
||||||
|
|
||||||
/// health_record 域埋点强类型封装(T2-13 创建漏斗三事件 + viewed)。
|
/// health_record 域埋点强类型封装(T2-13 创建漏斗三事件 + viewed)。
|
||||||
final HealthRecordAnalytics? healthRecordAnalytics;
|
final HealthRecordAnalytics? healthRecordAnalytics;
|
||||||
|
|
||||||
|
/// feed 域埋点(T3-14 聚合曝光 + 加载失败)。
|
||||||
|
final FeedAnalytics? feedAnalytics;
|
||||||
|
|
||||||
|
/// 互动域埋点(T3-16 评论成败对 + 关注对;详情页消费)。
|
||||||
|
final CommunityInteractionAnalytics? interactionAnalytics;
|
||||||
|
|
||||||
|
/// post 域埋点(T3-17 发布漏斗五事件 + 媒体三段;发布页消费)。
|
||||||
|
final PostAnalytics? postAnalytics;
|
||||||
|
|
||||||
|
/// 发布页 [MediaUploader] 构造口(桌面实测 / 测试替换选图与压缩层)。
|
||||||
|
final MediaUploaderFactory? mediaUploaderFactory;
|
||||||
|
|
||||||
/// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。
|
/// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。
|
||||||
final PageViewTracker? pageViewTracker;
|
final PageViewTracker? pageViewTracker;
|
||||||
|
|
||||||
@@ -83,16 +114,44 @@ class _MainShellPageState extends State<MainShellPage> {
|
|||||||
widget.pageViewTracker?.reportTab(_tabPages[3]);
|
widget.pageViewTracker?.reportTab(_tabPages[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
void openPost(PostModel post) {
|
/// 帖子详情(T3-15:真实数据整页;Feed 卡片与详情共享
|
||||||
|
/// communityController,互动状态跨页一致)。
|
||||||
|
void openPost(String postId) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName),
|
settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName),
|
||||||
builder: (context) =>
|
builder: (context) => PostDetailPage(
|
||||||
PostDetailPage(appState: widget.appState, postId: post.id),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
@@ -107,18 +166,17 @@ class _MainShellPageState extends State<MainShellPage> {
|
|||||||
final pages = [
|
final pages = [
|
||||||
HomePage(
|
HomePage(
|
||||||
appState: widget.appState,
|
appState: widget.appState,
|
||||||
onOpenPost: openPost,
|
communityController: widget.communityController,
|
||||||
|
feedAnalytics: widget.feedAnalytics,
|
||||||
|
isActive: currentIndex == 0,
|
||||||
onOpenServices: openServices,
|
onOpenServices: openServices,
|
||||||
onOpenCreate: () => selectTab(1),
|
onOpenCompose: () => openCompose(PostEntryPoint.feed),
|
||||||
|
onOpenPost: openPost,
|
||||||
),
|
),
|
||||||
CreatePage(
|
CreatePage(
|
||||||
appState: widget.appState,
|
appState: widget.appState,
|
||||||
onPublished: (post) {
|
// T3-17:发布半边已真实化(发布页 push),AI 生成模拟原样留 M4。
|
||||||
selectTab(0);
|
onOpenCompose: () => openCompose(PostEntryPoint.createTab),
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
|
||||||
(_) => openPost(post),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
PetsPage(
|
PetsPage(
|
||||||
controller: widget.petsController,
|
controller: widget.petsController,
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
/// 照护提醒创建表单页(T2-14)。
|
||||||
|
///
|
||||||
|
/// - 四类提醒类型(驱虫/体检/用药/其他)必选;标题必填;到期日期必选
|
||||||
|
/// (今日取此刻、未来日期取当日 12:00,转 UTC——与记录表单同一约定)。
|
||||||
|
/// - 契约:创建恒为 pending(不收 status);title/dueAt 无编辑端点,
|
||||||
|
/// 改期路径为忽略后重建。
|
||||||
|
/// - 埋点:health_record_create_started/succeeded/failed
|
||||||
|
/// (recordType=reminder)。
|
||||||
|
class CareReminderFormPage extends StatefulWidget {
|
||||||
|
const CareReminderFormPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.entryPoint = HealthRecordEntryPoint.recordList,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
final HealthRecordEntryPoint entryPoint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CareReminderFormPage> createState() => _CareReminderFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CareReminderFormPageState extends State<CareReminderFormPage> {
|
||||||
|
final _titleCtrl = TextEditingController();
|
||||||
|
|
||||||
|
CareReminderType? _type;
|
||||||
|
DateTime? _dueDate;
|
||||||
|
String? _typeError;
|
||||||
|
String? _titleError;
|
||||||
|
String? _dueError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
bool _startedFired = false;
|
||||||
|
int _attemptSeq = 0;
|
||||||
|
late final DateTime _openedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_openedAt = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markStarted() {
|
||||||
|
if (_startedFired) return;
|
||||||
|
_startedFired = true;
|
||||||
|
widget.analytics?.createStarted(
|
||||||
|
recordType: HealthRecordType.reminder,
|
||||||
|
entryPoint: widget.entryPoint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.createFailed(
|
||||||
|
recordType: HealthRecordType.reminder,
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: _attemptSeq,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 到期时刻:今日取此刻,其余日期取当日 12:00(本地),提交前转 UTC。
|
||||||
|
DateTime _dueAt() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final date = _dueDate!;
|
||||||
|
final sameDay =
|
||||||
|
date.year == now.year && date.month == now.month && date.day == now.day;
|
||||||
|
final local = sameDay ? now : DateTime(date.year, date.month, date.day, 12);
|
||||||
|
return local.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
_attemptSeq++;
|
||||||
|
final typeError = _type == null ? '请选择提醒类型' : null;
|
||||||
|
final titleError = _titleCtrl.text.trim().isEmpty ? '请输入提醒内容' : null;
|
||||||
|
final dueError = _dueDate == null ? '请选择到期日期' : null;
|
||||||
|
if (typeError != null || titleError != null || dueError != null) {
|
||||||
|
setState(() {
|
||||||
|
_typeError = typeError;
|
||||||
|
_titleError = titleError;
|
||||||
|
_dueError = dueError;
|
||||||
|
});
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final reminder = await widget.repository.createCareReminder(
|
||||||
|
widget.petId,
|
||||||
|
CreateCareReminderRequest(
|
||||||
|
reminderType: _type!,
|
||||||
|
title: _titleCtrl.text.trim(),
|
||||||
|
dueAt: _dueAt(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.analytics?.createSucceeded(
|
||||||
|
recordType: HealthRecordType.reminder,
|
||||||
|
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(reminder);
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('你没有权限为该宠物添加提醒');
|
||||||
|
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||||
|
} on PetNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
|
||||||
|
_trackFailed(HealthRecordFailureReason.notFound, 40401);
|
||||||
|
navigator.pop();
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('操作过于频繁,请稍后再试');
|
||||||
|
_trackFailed(HealthRecordFailureReason.rateLimited);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final isParam = error.code == ApiCodes.paramError;
|
||||||
|
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
|
||||||
|
_trackFailed(
|
||||||
|
isParam
|
||||||
|
? HealthRecordFailureReason.validationError
|
||||||
|
: HealthRecordFailureReason.serverError,
|
||||||
|
error.code,
|
||||||
|
);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_trackFailed(HealthRecordFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('添加照护提醒'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'提醒类型',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final type in CareReminderType.values)
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(careReminderTypeLabel(type)),
|
||||||
|
selected: _type == type,
|
||||||
|
onSelected: _submitting
|
||||||
|
? null
|
||||||
|
: (_) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_type = type;
|
||||||
|
_typeError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_typeError != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
_typeError!,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
AppTextField(
|
||||||
|
label: '提醒内容(如:体内外驱虫)',
|
||||||
|
controller: _titleCtrl,
|
||||||
|
prefixIcon: Icons.notifications_outlined,
|
||||||
|
errorText: _titleError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_titleError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_titleError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
),
|
||||||
|
tileColor: AppColors.surface,
|
||||||
|
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||||
|
title: const Text('到期日期', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
_dueDate == null ? '未选择' : dateToJson(_dueDate!),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.calendar_month_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
enabled: !_submitting,
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final value = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _dueDate ?? now,
|
||||||
|
firstDate: now,
|
||||||
|
lastDate: DateTime(now.year + 5),
|
||||||
|
);
|
||||||
|
if (value != null && mounted) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_dueDate = value;
|
||||||
|
_dueError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_dueError != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
_dueError!,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: '保存提醒',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
enum _ListPhase { loading, ready, error }
|
||||||
|
|
||||||
|
/// 照护提醒页(T2-14):契约不分页,`due_at ASC`(待办最先到期在前);
|
||||||
|
/// `?status=` 过滤走服务端白名单视图;逾期待办有显性视觉标识。
|
||||||
|
///
|
||||||
|
/// - 完成 / 忽略:`PATCH /care-reminders/{id}` 状态流转
|
||||||
|
/// (pending→completed 必带 completedAt,pending→dismissed 禁带);
|
||||||
|
/// 42202 规则兜底、40902 并发流转抢先提示后重拉。
|
||||||
|
/// - 提醒完成/忽略**不埋事件**(06 §7 缺口 3 既定取舍:完成率从
|
||||||
|
/// care_reminders 事实表出数;M3+ 推送实验时再增补)。
|
||||||
|
/// - 曝光埋点:每次进入首个成功加载上报一次
|
||||||
|
/// `health_record_viewed(recordType=reminder, source=pet_detail)`。
|
||||||
|
class CareRemindersPage extends StatefulWidget {
|
||||||
|
const CareRemindersPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
required this.canWrite,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
|
||||||
|
/// owner/caregiver 可写;viewer 隐藏创建与完成/忽略入口。
|
||||||
|
final bool canWrite;
|
||||||
|
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CareRemindersPage> createState() => _CareRemindersPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CareRemindersPageState extends State<CareRemindersPage> {
|
||||||
|
_ListPhase _phase = _ListPhase.loading;
|
||||||
|
List<CareReminder> _reminders = const [];
|
||||||
|
ApiException? _error;
|
||||||
|
CareReminderStatus? _filter;
|
||||||
|
bool _viewedFired = false;
|
||||||
|
bool _mutating = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_phase = _ListPhase.loading;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final reminders = await widget.repository.listCareReminders(
|
||||||
|
widget.petId,
|
||||||
|
status: _filter,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_reminders = reminders;
|
||||||
|
_phase = _ListPhase.ready;
|
||||||
|
});
|
||||||
|
if (!_viewedFired) {
|
||||||
|
_viewedFired = true;
|
||||||
|
widget.analytics?.viewed(
|
||||||
|
recordType: HealthRecordType.reminder,
|
||||||
|
source: HealthRecordViewSource.petDetail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error;
|
||||||
|
_phase = _ListPhase.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCreate() async {
|
||||||
|
final created = await Navigator.of(context).push<CareReminder>(
|
||||||
|
fadePageRoute(
|
||||||
|
CareReminderFormPage(
|
||||||
|
repository: widget.repository,
|
||||||
|
petId: widget.petId,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (created != null && mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已添加提醒')));
|
||||||
|
// 排序键 due_at ASC 在服务端,重新拉取而非本地猜位置。
|
||||||
|
await _load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完成时刻:今日取此刻,补记历史日期取当日 12:00(本地),转 UTC。
|
||||||
|
DateTime _completedAt(DateTime date) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final sameDay =
|
||||||
|
date.year == now.year && date.month == now.month && date.day == now.day;
|
||||||
|
final local = sameDay ? now : DateTime(date.year, date.month, date.day, 12);
|
||||||
|
return local.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _complete(CareReminder reminder) async {
|
||||||
|
final date = await showDialog<DateTime>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _CompleteDialog(title: reminder.title),
|
||||||
|
);
|
||||||
|
if (date == null || !mounted) return;
|
||||||
|
await _mutate(
|
||||||
|
reminder,
|
||||||
|
UpdateCareReminderRequest(
|
||||||
|
status: CareReminderStatus.completed,
|
||||||
|
// 契约:pending→completed 必带 completedAt(客户端提交,允许补记)。
|
||||||
|
completedAt: _completedAt(date),
|
||||||
|
),
|
||||||
|
successText: '已标记完成',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dismiss(CareReminder reminder) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('忽略这条提醒?'),
|
||||||
|
content: Text('「${reminder.title}」将不再出现在待办中,且不可恢复为待办。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('忽略'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
await _mutate(
|
||||||
|
reminder,
|
||||||
|
// 契约:pending→dismissed 禁带 completedAt。
|
||||||
|
const UpdateCareReminderRequest(status: CareReminderStatus.dismissed),
|
||||||
|
successText: '已忽略提醒',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _mutate(
|
||||||
|
CareReminder reminder,
|
||||||
|
UpdateCareReminderRequest request, {
|
||||||
|
required String successText,
|
||||||
|
}) async {
|
||||||
|
if (_mutating) return;
|
||||||
|
setState(() => _mutating = true);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
try {
|
||||||
|
await widget.repository.updateCareReminder(reminder.id, request);
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(successText)));
|
||||||
|
await _load();
|
||||||
|
} on CareReminderRuleException {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 42202:状态机/completedAt 一致性兜底(前端结构上已按状态发字段)。
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('提醒状态不满足流转规则,已刷新,请重试')),
|
||||||
|
);
|
||||||
|
await _load();
|
||||||
|
} on PetVersionConflictException {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 40902:并发流转抢先(条件更新守卫落空),处理方式与乐观锁一致。
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('提醒已在其他设备被处理,已刷新')));
|
||||||
|
await _load();
|
||||||
|
} on PetRecordNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('提醒不存在或已被删除,已刷新')));
|
||||||
|
await _load();
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该提醒')));
|
||||||
|
} on ApiBusinessException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _mutating = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('照护提醒'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (widget.canWrite && _phase == _ListPhase.ready)
|
||||||
|
IconButton(
|
||||||
|
tooltip: '添加提醒',
|
||||||
|
onPressed: _openCreate,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||||
|
child: _filterChips(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: switch (_phase) {
|
||||||
|
_ListPhase.loading => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
_ListPhase.error => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
InlineErrorBanner(message: petLoadErrorMessage(_error)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(onPressed: _load, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready when _reminders.isEmpty => Center(
|
||||||
|
child: SingleChildScrollView(child: _emptyState()),
|
||||||
|
),
|
||||||
|
_ListPhase.ready => _list(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 状态过滤(服务端白名单视图;「待办」即 ?status=pending 按 due_at 查询)。
|
||||||
|
Widget _filterChips() {
|
||||||
|
final options = <(String, CareReminderStatus?)>[
|
||||||
|
('全部', null),
|
||||||
|
('待办', CareReminderStatus.pending),
|
||||||
|
('已完成', CareReminderStatus.completed),
|
||||||
|
('已忽略', CareReminderStatus.dismissed),
|
||||||
|
];
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final (label, status) in options)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: _filter == status,
|
||||||
|
onSelected: (_) {
|
||||||
|
if (_filter == status) return;
|
||||||
|
setState(() => _filter = status);
|
||||||
|
_load();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _emptyState() {
|
||||||
|
// 过滤视图下的空态不给 CTA(05 §4.2 筛选后空态惯例)。
|
||||||
|
if (_filter != null) {
|
||||||
|
return EmptyStateIllustration(
|
||||||
|
icon: Icons.notifications_none_outlined,
|
||||||
|
title: '暂无「${careReminderStatusLabel(_filter!)}」提醒',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return EmptyStateIllustration(
|
||||||
|
icon: Icons.notifications_none_outlined,
|
||||||
|
title: '还没有照护提醒',
|
||||||
|
description: '驱虫、体检、用药……到点不忘每一件照护小事',
|
||||||
|
ctaLabel: widget.canWrite ? '添加第一条' : null,
|
||||||
|
onCtaPressed: widget.canWrite ? _openCreate : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _list() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
|
||||||
|
children: [
|
||||||
|
for (final reminder in _reminders) ...[
|
||||||
|
_ReminderTile(
|
||||||
|
reminder: reminder,
|
||||||
|
now: now,
|
||||||
|
// 完成/忽略仅对待办可用(终态不可迁;viewer 无写权限)。
|
||||||
|
onComplete:
|
||||||
|
widget.canWrite &&
|
||||||
|
reminder.status == CareReminderStatus.pending &&
|
||||||
|
!_mutating
|
||||||
|
? () => _complete(reminder)
|
||||||
|
: null,
|
||||||
|
onDismiss:
|
||||||
|
widget.canWrite &&
|
||||||
|
reminder.status == CareReminderStatus.pending &&
|
||||||
|
!_mutating
|
||||||
|
? () => _dismiss(reminder)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提醒条目:RecordTypeDot + 标题 + 时间副行 + 状态 TagPill(逾期红标),
|
||||||
|
/// 待办行附「标记完成 / 忽略」动作(图标 + 文字双通道)。
|
||||||
|
class _ReminderTile extends StatelessWidget {
|
||||||
|
const _ReminderTile({
|
||||||
|
required this.reminder,
|
||||||
|
required this.now,
|
||||||
|
this.onComplete,
|
||||||
|
this.onDismiss,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CareReminder reminder;
|
||||||
|
final DateTime now;
|
||||||
|
final VoidCallback? onComplete;
|
||||||
|
final VoidCallback? onDismiss;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final overdue = isReminderOverdue(reminder, now);
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
RecordTypeDot(
|
||||||
|
type: recordTypeForReminder(reminder.reminderType),
|
||||||
|
size: RecordTypeDotSize.md,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
reminder.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${careReminderTypeLabel(reminder.reminderType)} · '
|
||||||
|
'${reminderDateLine(reminder)}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
// 逾期副行同步警示色(与标签双位标识)。
|
||||||
|
color: overdue
|
||||||
|
? AppColors.errorDark
|
||||||
|
: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: overdue
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TagPill(
|
||||||
|
reminderStatusTag(reminder, now),
|
||||||
|
color: reminderStatusColor(reminder, now),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (onComplete != null || onDismiss != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onDismiss,
|
||||||
|
icon: const Icon(Icons.close, size: 16),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
label: const Text('忽略'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onComplete,
|
||||||
|
icon: const Icon(Icons.check_circle_outline, size: 16),
|
||||||
|
label: const Text('标记完成'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记完成对话框:完成日期默认今天,可补记历史时刻(契约 completedAt
|
||||||
|
/// 由客户端提交)。确认返回所选日期。
|
||||||
|
class _CompleteDialog extends StatefulWidget {
|
||||||
|
const _CompleteDialog({required this.title});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CompleteDialog> createState() => _CompleteDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompleteDialogState extends State<_CompleteDialog> {
|
||||||
|
DateTime _date = DateTime.now();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('标记完成'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'「${widget.title}」',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||||
|
title: const Text('完成日期', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
dateToJson(_date),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final value = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _date,
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: now,
|
||||||
|
);
|
||||||
|
if (value != null && mounted) {
|
||||||
|
setState(() => _date = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(_date),
|
||||||
|
child: const Text('确认完成'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/money.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
/// 健康事件编辑页(T2-14):顶层短路径 `PATCH /health-events/{id}`。
|
||||||
|
///
|
||||||
|
/// - 契约:仅 title / notes / amountCents 可编辑;eventType / occurredAt
|
||||||
|
/// 为条目身份,静态展示不可改;不支持清空回 null(清空输入视为不变更)。
|
||||||
|
/// - 差量提交:只发送改动字段 + version;无变更不发 PATCH 直接返回。
|
||||||
|
/// - 40902 版本冲突照 T2-12 模式:明确提示 + 自动取最新版本更新乐观锁
|
||||||
|
/// 基线(保留用户输入),用户核对后重新保存。契约无按 id 读取端点,
|
||||||
|
/// 最新版本经时间线分页检索取回。
|
||||||
|
/// - 埋点:health_record_edit_succeeded(fieldCount) / edit_failed
|
||||||
|
/// (failureReason 含 conflict,recordType=health_event)。
|
||||||
|
class HealthEventEditPage extends StatefulWidget {
|
||||||
|
const HealthEventEditPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.event,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
|
||||||
|
/// 编辑基线(含 version 乐观锁与差量比较基准)。
|
||||||
|
final HealthEvent event;
|
||||||
|
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HealthEventEditPage> createState() => _HealthEventEditPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HealthEventEditPageState extends State<HealthEventEditPage> {
|
||||||
|
late final TextEditingController _titleCtrl;
|
||||||
|
late final TextEditingController _amountCtrl;
|
||||||
|
late final TextEditingController _notesCtrl;
|
||||||
|
|
||||||
|
/// 编辑基线:40902 冲突刷新后更新(version 与差量计算的比较基准)。
|
||||||
|
late HealthEvent _base;
|
||||||
|
|
||||||
|
String? _titleError;
|
||||||
|
String? _amountError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_base = widget.event;
|
||||||
|
_titleCtrl = TextEditingController(text: _base.title);
|
||||||
|
_amountCtrl = TextEditingController(
|
||||||
|
text: _base.amountCents == null
|
||||||
|
? ''
|
||||||
|
: formatCentsAsYuan(_base.amountCents!),
|
||||||
|
);
|
||||||
|
_notesCtrl = TextEditingController(text: _base.notes ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleCtrl.dispose();
|
||||||
|
_amountCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.editFailed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
reason: reason,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 差量请求:只含改动字段(契约不支持清空回 null——清空输入视为不变更)。
|
||||||
|
/// 无实际变更返回 null。
|
||||||
|
UpdateHealthEventRequest? _buildDiff() {
|
||||||
|
final title = _titleCtrl.text.trim();
|
||||||
|
final notes = _notesCtrl.text.trim();
|
||||||
|
final amountText = _amountCtrl.text.trim();
|
||||||
|
final amountCents = amountText.isEmpty
|
||||||
|
? null
|
||||||
|
: parseYuanToCents(amountText);
|
||||||
|
final request = UpdateHealthEventRequest(
|
||||||
|
version: _base.version,
|
||||||
|
title: title != _base.title ? title : null,
|
||||||
|
notes: notes.isNotEmpty && notes != (_base.notes ?? '') ? notes : null,
|
||||||
|
amountCents: amountCents != null && amountCents != _base.amountCents
|
||||||
|
? amountCents
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
// 只剩 version 一个键 → 无实际变更。
|
||||||
|
return request.toJson().length == 1 ? null : request;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
final titleError = _titleCtrl.text.trim().isEmpty ? '标题不能为空' : null;
|
||||||
|
final amountText = _amountCtrl.text.trim();
|
||||||
|
final amountError =
|
||||||
|
amountText.isNotEmpty && parseYuanToCents(amountText) == null
|
||||||
|
? '金额格式不正确,最多两位小数'
|
||||||
|
: null;
|
||||||
|
if (titleError != null || amountError != null) {
|
||||||
|
setState(() {
|
||||||
|
_titleError = titleError;
|
||||||
|
_amountError = amountError;
|
||||||
|
});
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final request = _buildDiff();
|
||||||
|
if (request == null) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final updated = await widget.repository.updateHealthEvent(
|
||||||
|
_base.id,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
widget.analytics?.editSucceeded(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
fieldCount: request.toJson().length - 1,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(updated);
|
||||||
|
} on PetVersionConflictException {
|
||||||
|
_trackFailed(HealthRecordFailureReason.conflict, 40902);
|
||||||
|
await _handleVersionConflict();
|
||||||
|
} on PetRecordNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除')));
|
||||||
|
_trackFailed(HealthRecordFailureReason.notFound, 40402);
|
||||||
|
navigator.pop();
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('你没有权限修改该记录');
|
||||||
|
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('操作过于频繁,请稍后再试');
|
||||||
|
_trackFailed(HealthRecordFailureReason.rateLimited);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final isParam = error.code == ApiCodes.paramError;
|
||||||
|
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
|
||||||
|
_trackFailed(
|
||||||
|
isParam
|
||||||
|
? HealthRecordFailureReason.validationError
|
||||||
|
: HealthRecordFailureReason.serverError,
|
||||||
|
error.code,
|
||||||
|
);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_trackFailed(HealthRecordFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40902:提示 + 刷新路径——契约无按 id 读取端点,经时间线分页检索
|
||||||
|
/// 取回最新版本更新乐观锁基线(保留用户输入),用户核对后重新保存。
|
||||||
|
Future<void> _handleVersionConflict() async {
|
||||||
|
try {
|
||||||
|
final fresh = await _fetchLatest();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (fresh == null) {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除')));
|
||||||
|
navigator.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _base = fresh);
|
||||||
|
_showFormError('记录已在其他设备被修改,已获取最新版本,请核对后重新保存');
|
||||||
|
} on ApiException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('记录已在其他设备被修改,请返回后刷新重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 occurred_at DESC 分页检索本记录(上限 10 页防御性截断)。
|
||||||
|
Future<HealthEvent?> _fetchLatest() async {
|
||||||
|
String? cursor;
|
||||||
|
for (var page = 0; page < 10; page++) {
|
||||||
|
final result = await widget.repository.listHealthEvents(
|
||||||
|
_base.petId,
|
||||||
|
limit: 50,
|
||||||
|
cursor: cursor,
|
||||||
|
);
|
||||||
|
for (final item in result.items) {
|
||||||
|
if (item.id == _base.id) return item;
|
||||||
|
}
|
||||||
|
if (!result.hasMore) return null;
|
||||||
|
cursor = result.nextCursor;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final style = recordTypeStyles[recordTypeForHealthEvent(_base.eventType)]!;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('编辑健康事件'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
||||||
|
children: [
|
||||||
|
// 事件身份静态区:类型与发生时间不可编辑(契约不在请求体)。
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
RecordTypeDot(
|
||||||
|
type: recordTypeForHealthEvent(_base.eventType),
|
||||||
|
size: RecordTypeDotSize.md,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
healthEventTypeLabel(_base.eventType),
|
||||||
|
style: TextStyle(
|
||||||
|
color: style.inkColor,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
formatOccurredAt(_base.occurredAt),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
AppTextField(
|
||||||
|
label: '标题',
|
||||||
|
controller: _titleCtrl,
|
||||||
|
prefixIcon: Icons.title_outlined,
|
||||||
|
errorText: _titleError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_titleError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_titleError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '金额(元)',
|
||||||
|
controller: _amountCtrl,
|
||||||
|
prefixIcon: Icons.payments_outlined,
|
||||||
|
errorText: _amountError,
|
||||||
|
helperText: '以元填写,最多两位小数;清空视为不变更',
|
||||||
|
enabled: !_submitting,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_amountError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_amountError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '备注',
|
||||||
|
controller: _notesCtrl,
|
||||||
|
prefixIcon: Icons.sticky_note_2_outlined,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: '保存修改',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/money.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
/// 健康事件录入表单页(T2-14)。
|
||||||
|
///
|
||||||
|
/// - 六类事件类型选择(05 §4.4 类型选择器形态:RecordTypeDot sm + 标签,
|
||||||
|
/// 图标 + 文字双通道);类型必选。
|
||||||
|
/// - 发生时间默认「现在」,可回选日期(非今日取当日 12:00,与体重表单
|
||||||
|
/// 同一约定);提交前转 UTC,ISO 8601 带 Z 上送。
|
||||||
|
/// - 金额可选:**UI 以元录入/展示,传输为整数分**(money.dart 换算,
|
||||||
|
/// 最多两位小数,非法拦截);后端提交小数 400/40000 兜底。
|
||||||
|
/// - 埋点:health_record_create_started/succeeded/failed
|
||||||
|
/// (recordType=health_event)。
|
||||||
|
class HealthEventFormPage extends StatefulWidget {
|
||||||
|
const HealthEventFormPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.entryPoint = HealthRecordEntryPoint.recordList,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
final HealthRecordEntryPoint entryPoint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HealthEventFormPage> createState() => _HealthEventFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HealthEventFormPageState extends State<HealthEventFormPage> {
|
||||||
|
final _titleCtrl = TextEditingController();
|
||||||
|
final _amountCtrl = TextEditingController();
|
||||||
|
final _notesCtrl = TextEditingController();
|
||||||
|
|
||||||
|
HealthEventType? _type;
|
||||||
|
DateTime _occurredDate = DateTime.now();
|
||||||
|
String? _typeError;
|
||||||
|
String? _titleError;
|
||||||
|
String? _amountError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
bool _startedFired = false;
|
||||||
|
int _attemptSeq = 0;
|
||||||
|
late final DateTime _openedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_openedAt = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleCtrl.dispose();
|
||||||
|
_amountCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markStarted() {
|
||||||
|
if (_startedFired) return;
|
||||||
|
_startedFired = true;
|
||||||
|
widget.analytics?.createStarted(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
entryPoint: widget.entryPoint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.createFailed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: _attemptSeq,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发生时刻:今日取此刻,历史日期取当日 12:00(本地),提交前转 UTC
|
||||||
|
/// (与体重表单同一约定,规避无时区后缀的解析歧义)。
|
||||||
|
DateTime _occurredAt() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final sameDay =
|
||||||
|
_occurredDate.year == now.year &&
|
||||||
|
_occurredDate.month == now.month &&
|
||||||
|
_occurredDate.day == now.day;
|
||||||
|
final local = sameDay
|
||||||
|
? now
|
||||||
|
: DateTime(
|
||||||
|
_occurredDate.year,
|
||||||
|
_occurredDate.month,
|
||||||
|
_occurredDate.day,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
return local.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
_attemptSeq++;
|
||||||
|
final typeError = _type == null ? '请选择事件类型' : null;
|
||||||
|
final titleError = _titleCtrl.text.trim().isEmpty ? '请输入标题' : null;
|
||||||
|
final amountText = _amountCtrl.text.trim();
|
||||||
|
final amountError =
|
||||||
|
amountText.isNotEmpty && parseYuanToCents(amountText) == null
|
||||||
|
? '金额格式不正确,最多两位小数'
|
||||||
|
: null;
|
||||||
|
if (typeError != null || titleError != null || amountError != null) {
|
||||||
|
setState(() {
|
||||||
|
_typeError = typeError;
|
||||||
|
_titleError = titleError;
|
||||||
|
_amountError = amountError;
|
||||||
|
});
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final notes = _notesCtrl.text.trim();
|
||||||
|
final record = await widget.repository.createHealthEvent(
|
||||||
|
widget.petId,
|
||||||
|
CreateHealthEventRequest(
|
||||||
|
eventType: _type!,
|
||||||
|
occurredAt: _occurredAt(),
|
||||||
|
title: _titleCtrl.text.trim(),
|
||||||
|
notes: notes.isEmpty ? null : notes,
|
||||||
|
// 元 → 整数分换算,DTO 层保持整数分(开发计划 §4.3)。
|
||||||
|
amountCents: amountText.isEmpty ? null : parseYuanToCents(amountText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.analytics?.createSucceeded(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(record);
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('你没有权限为该宠物添加记录');
|
||||||
|
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||||
|
} on PetNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
|
||||||
|
_trackFailed(HealthRecordFailureReason.notFound, 40401);
|
||||||
|
navigator.pop();
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('操作过于频繁,请稍后再试');
|
||||||
|
_trackFailed(HealthRecordFailureReason.rateLimited);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final isParam = error.code == ApiCodes.paramError;
|
||||||
|
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
|
||||||
|
_trackFailed(
|
||||||
|
isParam
|
||||||
|
? HealthRecordFailureReason.validationError
|
||||||
|
: HealthRecordFailureReason.serverError,
|
||||||
|
error.code,
|
||||||
|
);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_trackFailed(HealthRecordFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('记录健康事件'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'事件类型',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_typeSelector(),
|
||||||
|
if (_typeError != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
_typeError!,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
AppTextField(
|
||||||
|
label: '标题(如:皮肤检查)',
|
||||||
|
controller: _titleCtrl,
|
||||||
|
prefixIcon: Icons.title_outlined,
|
||||||
|
errorText: _titleError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_titleError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_titleError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
),
|
||||||
|
tileColor: AppColors.surface,
|
||||||
|
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||||
|
title: const Text('发生日期', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
dateToJson(_occurredDate),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.calendar_month_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
enabled: !_submitting,
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final value = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _occurredDate,
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: now,
|
||||||
|
);
|
||||||
|
if (value != null && mounted) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() => _occurredDate = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '金额(元,可选)',
|
||||||
|
controller: _amountCtrl,
|
||||||
|
prefixIcon: Icons.payments_outlined,
|
||||||
|
errorText: _amountError,
|
||||||
|
helperText: '以元填写,最多两位小数,如 128.50',
|
||||||
|
enabled: !_submitting,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_amountError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_amountError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '备注(可选)',
|
||||||
|
controller: _notesCtrl,
|
||||||
|
prefixIcon: Icons.sticky_note_2_outlined,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (_) => _markStarted(),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: '保存记录',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 六类类型选择器(05 §4.4:RecordTypeDot sm 上、标签下;
|
||||||
|
/// 选中 surfaceTint 底 + primaryDark 标签,未选中 inkSoft)。
|
||||||
|
Widget _typeSelector() {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final type in HealthEventType.values)
|
||||||
|
_TypeUnit(
|
||||||
|
type: type,
|
||||||
|
selected: _type == type,
|
||||||
|
enabled: !_submitting,
|
||||||
|
onTap: () {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_type = type;
|
||||||
|
_typeError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypeUnit extends StatelessWidget {
|
||||||
|
const _TypeUnit({
|
||||||
|
required this.type,
|
||||||
|
required this.selected,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HealthEventType type;
|
||||||
|
final bool selected;
|
||||||
|
final bool enabled;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: selected ? AppColors.surfaceTint : AppColors.surface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
color: selected ? AppColors.primaryStrong : AppColors.border,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 64, minHeight: 52),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
RecordTypeDot(
|
||||||
|
type: recordTypeForHealthEvent(type),
|
||||||
|
size: RecordTypeDotSize.sm,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
healthEventTypeLabel(type),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||||||
|
color: selected ? AppColors.primaryDark : AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_event_edit_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_event_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/money.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
enum _ListPhase { loading, ready, error }
|
||||||
|
|
||||||
|
/// 健康事件时间线页(T2-14):cursor 分页(occurred_at DESC,
|
||||||
|
/// 「加载更多」追加,末页收起),按月分组(05 §4.2 组头),四态齐备。
|
||||||
|
///
|
||||||
|
/// - 六类事件经 [recordTypeForHealthEvent] 映射 RecordTypeDot,
|
||||||
|
/// 类型标签 TagPill 双通道呈现;金额以元展示(传输为整数分)。
|
||||||
|
/// - 录入经 [HealthEventFormPage];成功后重拉首页(排序与月分组以
|
||||||
|
/// 服务端为准,不本地猜位置)。
|
||||||
|
/// - 编辑经 [HealthEventEditPage](canWrite 点条目进入);成功就地替换。
|
||||||
|
/// - 曝光埋点:每次进入首个成功加载上报一次
|
||||||
|
/// `health_record_viewed(recordType=health_event, source=pet_detail)`。
|
||||||
|
class HealthEventsPage extends StatefulWidget {
|
||||||
|
const HealthEventsPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
required this.canWrite,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
|
||||||
|
/// owner/caregiver 可写;viewer 隐藏录入/编辑入口(40300 语义前置)。
|
||||||
|
final bool canWrite;
|
||||||
|
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
|
||||||
|
/// 每页条数(测试注入小页验证分页;缺省走服务端默认 20)。
|
||||||
|
final int? pageSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HealthEventsPage> createState() => _HealthEventsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HealthEventsPageState extends State<HealthEventsPage> {
|
||||||
|
_ListPhase _phase = _ListPhase.loading;
|
||||||
|
List<HealthEvent> _events = const [];
|
||||||
|
String? _nextCursor;
|
||||||
|
bool _hasMore = false;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
ApiException? _error;
|
||||||
|
bool _viewedFired = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadFirstPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFirstPage() async {
|
||||||
|
setState(() {
|
||||||
|
_phase = _ListPhase.loading;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final page = await widget.repository.listHealthEvents(
|
||||||
|
widget.petId,
|
||||||
|
limit: widget.pageSize,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_events = page.items;
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_phase = _ListPhase.ready;
|
||||||
|
});
|
||||||
|
if (!_viewedFired) {
|
||||||
|
_viewedFired = true;
|
||||||
|
widget.analytics?.viewed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
source: HealthRecordViewSource.petDetail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error;
|
||||||
|
_phase = _ListPhase.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMore() async {
|
||||||
|
if (_loadingMore || !_hasMore) return;
|
||||||
|
setState(() => _loadingMore = true);
|
||||||
|
try {
|
||||||
|
final page = await widget.repository.listHealthEvents(
|
||||||
|
widget.petId,
|
||||||
|
limit: widget.pageSize,
|
||||||
|
cursor: _nextCursor,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_events = [..._events, ...page.items];
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
});
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loadingMore = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCreate() async {
|
||||||
|
final created = await Navigator.of(context).push<HealthEvent>(
|
||||||
|
fadePageRoute(
|
||||||
|
HealthEventFormPage(
|
||||||
|
repository: widget.repository,
|
||||||
|
petId: widget.petId,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
// record_form:健康记录漏斗到达段页名(06 §1.6 / §2.2)。
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (created != null && mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已记录健康事件')));
|
||||||
|
// occurred_at DESC + 月分组:补录历史日期的位置由服务端定,
|
||||||
|
// 重拉首页而非本地猜位置。
|
||||||
|
await _loadFirstPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEdit(HealthEvent event) async {
|
||||||
|
final updated = await Navigator.of(context).push<HealthEvent>(
|
||||||
|
// 编辑页不带路由名:record_form 专属创建漏斗到达段(T2-12 先例)。
|
||||||
|
fadePageRoute(
|
||||||
|
HealthEventEditPage(
|
||||||
|
repository: widget.repository,
|
||||||
|
event: event,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (updated != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
// occurredAt 不可编辑 → 排序/分组位置不变,就地替换安全。
|
||||||
|
_events = [
|
||||||
|
for (final item in _events)
|
||||||
|
if (item.id == updated.id) updated else item,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已保存修改')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('健康时间线'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (widget.canWrite && _phase == _ListPhase.ready)
|
||||||
|
IconButton(
|
||||||
|
tooltip: '记录健康事件',
|
||||||
|
onPressed: _openCreate,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: switch (_phase) {
|
||||||
|
_ListPhase.loading => const Center(child: CircularProgressIndicator()),
|
||||||
|
_ListPhase.error => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
InlineErrorBanner(message: petLoadErrorMessage(_error)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _loadFirstPage,
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready when _events.isEmpty => Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: EmptyStateIllustration(
|
||||||
|
icon: Icons.event_note_outlined,
|
||||||
|
title: '还没有健康记录',
|
||||||
|
description: '就医、驱虫、洗护……随手记下毛孩子的健康点滴',
|
||||||
|
ctaLabel: widget.canWrite ? '记录第一条' : null,
|
||||||
|
onCtaPressed: widget.canWrite ? _openCreate : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready => _list(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按月分组渲染:服务端 occurred_at DESC 保证同月相邻,
|
||||||
|
/// 月份变化处插组头(05 §4.2「2026 年 9 月」)。
|
||||||
|
Widget _list() {
|
||||||
|
final children = <Widget>[];
|
||||||
|
String? currentMonth;
|
||||||
|
for (final event in _events) {
|
||||||
|
final month = healthEventMonthHeader(event.occurredAt);
|
||||||
|
if (month != currentMonth) {
|
||||||
|
currentMonth = month;
|
||||||
|
children.add(
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: children.isEmpty ? 0 : 14, bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
month,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
children
|
||||||
|
..add(
|
||||||
|
_HealthEventTile(
|
||||||
|
event: event,
|
||||||
|
onTap: widget.canWrite ? () => _openEdit(event) : null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(const SizedBox(height: 10));
|
||||||
|
}
|
||||||
|
if (_hasMore) {
|
||||||
|
children.add(
|
||||||
|
Center(
|
||||||
|
child: _loadingMore
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: TextButton(onPressed: _loadMore, child: const Text('加载更多')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadFirstPage,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间线条目(05 §3.3 形态):RecordTypeDot(六类映射) + 标题 +
|
||||||
|
/// 日期/备注副行;尾部类型 TagPill(双通道)与金额(元展示,
|
||||||
|
/// 15/w800 类型文字色)。
|
||||||
|
class _HealthEventTile extends StatelessWidget {
|
||||||
|
const _HealthEventTile({required this.event, this.onTap});
|
||||||
|
|
||||||
|
final HealthEvent event;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final recordType = recordTypeForHealthEvent(event.eventType);
|
||||||
|
final style = recordTypeStyles[recordType]!;
|
||||||
|
final meta = StringBuffer(formatOccurredAt(event.occurredAt));
|
||||||
|
if (event.notes != null && event.notes!.isNotEmpty) {
|
||||||
|
meta.write(' · ${event.notes}');
|
||||||
|
}
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
RecordTypeDot(type: recordType, size: RecordTypeDotSize.md),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
event.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
meta.toString(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TagPill(
|
||||||
|
healthEventTypeLabel(event.eventType),
|
||||||
|
color: style.baseColor,
|
||||||
|
),
|
||||||
|
if (event.amountCents != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'¥${formatCentsAsYuan(event.amountCents!)}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: style.inkColor,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
|||||||
/// 已扩充就绪,24 号报告 §2.2)。沿用 13 号规范 §3.1 惯例:枚举编译期
|
/// 已扩充就绪,24 号报告 §2.2)。沿用 13 号规范 §3.1 惯例:枚举编译期
|
||||||
/// 锁死,业务代码禁止手拼事件名与属性。
|
/// 锁死,业务代码禁止手拼事件名与属性。
|
||||||
///
|
///
|
||||||
/// T2-13 挂接创建漏斗三事件 + viewed;edit/deleted 事件的挂接随
|
/// T2-13 挂接创建漏斗三事件 + viewed;T2-14 补挂 edit_succeeded/failed
|
||||||
/// 编辑/删除交互落地(「标记完成」等)另行接线,见 25 号报告遗留。
|
/// (健康事件编辑、疫苗标记完成/取消)。`health_record_deleted` 因 M2
|
||||||
|
/// 契约无删除端点暂无挂接点,留待删除交互落地。
|
||||||
|
|
||||||
/// 记录类型(06 §1.4 recordType 枚举,四类记录接口对应)。
|
/// 记录类型(06 §1.4 recordType 枚举,四类记录接口对应)。
|
||||||
enum HealthRecordType {
|
enum HealthRecordType {
|
||||||
@@ -30,7 +31,8 @@ enum HealthRecordEntryPoint {
|
|||||||
final String value;
|
final String value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建失败原因(06 §1.4 基底 + M2 验收新增三值)。与 pet 域同款
|
/// 创建/编辑失败原因(06 §1.4 基底 + M2 验收新增三值;`conflict`
|
||||||
|
/// 仅编辑链路会出现——40902 乐观锁/条件更新守卫落空)。与 pet 域同款
|
||||||
/// 网络归并口径:断网/超时/5xx 均并入 network_error,server_error
|
/// 网络归并口径:断网/超时/5xx 均并入 network_error,server_error
|
||||||
/// 保留给无法归类的兜底。
|
/// 保留给无法归类的兜底。
|
||||||
enum HealthRecordFailureReason {
|
enum HealthRecordFailureReason {
|
||||||
@@ -39,7 +41,8 @@ enum HealthRecordFailureReason {
|
|||||||
notFound('not_found'),
|
notFound('not_found'),
|
||||||
rateLimited('rate_limited'),
|
rateLimited('rate_limited'),
|
||||||
networkError('network_error'),
|
networkError('network_error'),
|
||||||
serverError('server_error');
|
serverError('server_error'),
|
||||||
|
conflict('conflict');
|
||||||
|
|
||||||
const HealthRecordFailureReason(this.value);
|
const HealthRecordFailureReason(this.value);
|
||||||
|
|
||||||
@@ -122,4 +125,34 @@ class HealthRecordAnalytics {
|
|||||||
'source': source.value,
|
'source': source.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 编辑保存成功响应(06 §1.4:编辑不设 started)。
|
||||||
|
///
|
||||||
|
/// [fieldCount] 为本次变更字段数(差量 PATCH 的键数,不含 version)。
|
||||||
|
void editSucceeded({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required int fieldCount,
|
||||||
|
}) {
|
||||||
|
_track('health_record_edit_succeeded', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'fieldCount': fieldCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑保存失败(失败原因含 `conflict`——40902 版本/状态守卫冲突,
|
||||||
|
/// M2 验收「并发冲突明确」场景的数据面)。属性集无 attemptSeq
|
||||||
|
/// (白名单对齐 06 §1.5)。
|
||||||
|
void editFailed({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required HealthRecordFailureReason reason,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('health_record_edit_failed', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/// 体重 / 疫苗展示与输入解析的纯函数集合(列表 / 表单共用,可单测)。
|
/// 体重 / 疫苗 / 健康事件 / 提醒展示与输入解析的纯函数集合
|
||||||
|
/// (列表 / 表单共用,可单测)。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
/// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。
|
/// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。
|
||||||
@@ -84,3 +86,105 @@ String? vaccinationDateRuleError({
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 健康事件(T2-14)----
|
||||||
|
|
||||||
|
/// 契约六类健康事件 → 视觉记录类型(RecordTypeDot 映射唯一出口)。
|
||||||
|
/// note(随手记)归入「其他」视觉族;其余五类各有专属图标。
|
||||||
|
RecordType recordTypeForHealthEvent(HealthEventType type) => switch (type) {
|
||||||
|
HealthEventType.medical => RecordType.medical,
|
||||||
|
HealthEventType.feeding => RecordType.feeding,
|
||||||
|
HealthEventType.deworming => RecordType.deworming,
|
||||||
|
HealthEventType.grooming => RecordType.grooming,
|
||||||
|
HealthEventType.measurement => RecordType.measurement,
|
||||||
|
HealthEventType.note => RecordType.other,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 六类事件中文文案(类型选择器 / 条目标签共用;
|
||||||
|
/// 图标 + 文字双通道,不单靠颜色区分)。
|
||||||
|
String healthEventTypeLabel(HealthEventType type) => switch (type) {
|
||||||
|
HealthEventType.medical => '就医',
|
||||||
|
HealthEventType.feeding => '喂养',
|
||||||
|
HealthEventType.deworming => '驱虫',
|
||||||
|
HealthEventType.grooming => '洗护',
|
||||||
|
HealthEventType.measurement => '测量',
|
||||||
|
HealthEventType.note => '随手记',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 事件发生时刻展示:本地时区 `YYYY-MM-DD HH:mm`。
|
||||||
|
String formatOccurredAt(DateTime occurredAt) {
|
||||||
|
final local = occurredAt.toLocal();
|
||||||
|
final h = local.hour.toString().padLeft(2, '0');
|
||||||
|
final min = local.minute.toString().padLeft(2, '0');
|
||||||
|
return '${dateToJson(local)} $h:$min';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间线月分组组头(05 §4.2:「2026 年 9 月」),按本地时区归月。
|
||||||
|
String healthEventMonthHeader(DateTime occurredAt) {
|
||||||
|
final local = occurredAt.toLocal();
|
||||||
|
return '${local.year} 年 ${local.month} 月';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设备时区 → summary `tz` 参数(契约接受固定偏移形如 `+08:00`;
|
||||||
|
/// Flutter 无 IANA 名可取,固定偏移语义等价——只作用于月度窗口)。
|
||||||
|
String tzOffsetQueryValue(Duration offset) {
|
||||||
|
final sign = offset.isNegative ? '-' : '+';
|
||||||
|
final abs = offset.abs();
|
||||||
|
final h = abs.inHours.toString().padLeft(2, '0');
|
||||||
|
final m = (abs.inMinutes % 60).toString().padLeft(2, '0');
|
||||||
|
return '$sign$h:$m';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 照护提醒(T2-14)----
|
||||||
|
|
||||||
|
/// 四类提醒中文文案。
|
||||||
|
String careReminderTypeLabel(CareReminderType type) => switch (type) {
|
||||||
|
CareReminderType.deworming => '驱虫',
|
||||||
|
CareReminderType.checkup => '体检',
|
||||||
|
CareReminderType.medication => '用药',
|
||||||
|
CareReminderType.other => '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 提醒类型 → 视觉记录类型(RecordTypeDot 复用):驱虫沿用驱虫族,
|
||||||
|
/// 体检/用药归就医族,其他归兜底族;类型文字由标签承载(双通道)。
|
||||||
|
RecordType recordTypeForReminder(CareReminderType type) => switch (type) {
|
||||||
|
CareReminderType.deworming => RecordType.deworming,
|
||||||
|
CareReminderType.checkup => RecordType.medical,
|
||||||
|
CareReminderType.medication => RecordType.medical,
|
||||||
|
CareReminderType.other => RecordType.other,
|
||||||
|
};
|
||||||
|
|
||||||
|
String careReminderStatusLabel(CareReminderStatus status) => switch (status) {
|
||||||
|
CareReminderStatus.pending => '待办',
|
||||||
|
CareReminderStatus.completed => '已完成',
|
||||||
|
CareReminderStatus.dismissed => '已忽略',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 逾期判定:待办且 dueAt 已过(工单硬项:逾期视觉标识)。
|
||||||
|
bool isReminderOverdue(CareReminder reminder, DateTime now) =>
|
||||||
|
reminder.status == CareReminderStatus.pending &&
|
||||||
|
reminder.dueAt.isBefore(now);
|
||||||
|
|
||||||
|
/// 提醒状态标签文案(逾期的待办以「已逾期」显性标识)。
|
||||||
|
String reminderStatusTag(CareReminder reminder, DateTime now) =>
|
||||||
|
isReminderOverdue(reminder, now)
|
||||||
|
? '已逾期'
|
||||||
|
: careReminderStatusLabel(reminder.status);
|
||||||
|
|
||||||
|
/// 提醒状态标签基色(TagPill 淡染底;文字深变体由 TagPill 内置映射)。
|
||||||
|
Color reminderStatusColor(CareReminder reminder, DateTime now) =>
|
||||||
|
switch (reminder.status) {
|
||||||
|
CareReminderStatus.pending =>
|
||||||
|
isReminderOverdue(reminder, now) ? AppColors.error : AppColors.accent,
|
||||||
|
CareReminderStatus.completed => AppColors.success,
|
||||||
|
CareReminderStatus.dismissed => AppColors.muted,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 提醒条目副行:按状态给时间语义(到期 / 完成于 / 已忽略)。
|
||||||
|
String reminderDateLine(CareReminder reminder) => switch (reminder.status) {
|
||||||
|
CareReminderStatus.pending => '到期 ${formatOccurredAt(reminder.dueAt)}',
|
||||||
|
CareReminderStatus.completed =>
|
||||||
|
'完成于 ${reminder.completedAt == null ? '—' : formatOccurredAt(reminder.completedAt!)}',
|
||||||
|
CareReminderStatus.dismissed =>
|
||||||
|
'已忽略 · 原到期 ${formatOccurredAt(reminder.dueAt)}',
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
|||||||
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||||||
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/care_reminders_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_events_page.dart';
|
||||||
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/money.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
@@ -22,6 +25,8 @@ enum _DetailPhase { loading, ready, error, notFound }
|
|||||||
|
|
||||||
enum _SummaryPhase { loading, ready, error }
|
enum _SummaryPhase { loading, ready, error }
|
||||||
|
|
||||||
|
enum _RemindersPhase { loading, ready, error }
|
||||||
|
|
||||||
/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。
|
/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。
|
||||||
///
|
///
|
||||||
/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet]
|
/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet]
|
||||||
@@ -57,6 +62,9 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
_SummaryPhase _summaryPhase = _SummaryPhase.loading;
|
_SummaryPhase _summaryPhase = _SummaryPhase.loading;
|
||||||
PetSummary? _summary;
|
PetSummary? _summary;
|
||||||
|
|
||||||
|
_RemindersPhase _remindersPhase = _RemindersPhase.loading;
|
||||||
|
List<CareReminder> _pendingReminders = const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -64,6 +72,7 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
if (_pet != null) _phase = _DetailPhase.ready;
|
if (_pet != null) _phase = _DetailPhase.ready;
|
||||||
_load();
|
_load();
|
||||||
_loadSummary();
|
_loadSummary();
|
||||||
|
_loadPendingReminders();
|
||||||
}
|
}
|
||||||
|
|
||||||
Pet? _fromController() {
|
Pet? _fromController() {
|
||||||
@@ -104,11 +113,14 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。
|
/// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。
|
||||||
|
/// `tz` 透传设备时区固定偏移(T2-13 遗留③):monthlyExpense 的月度
|
||||||
|
/// 窗口随设备时区取边界,与用户直觉一致。
|
||||||
Future<void> _loadSummary() async {
|
Future<void> _loadSummary() async {
|
||||||
setState(() => _summaryPhase = _SummaryPhase.loading);
|
setState(() => _summaryPhase = _SummaryPhase.loading);
|
||||||
try {
|
try {
|
||||||
final summary = await widget.controller.repository.getPetSummary(
|
final summary = await widget.controller.repository.getPetSummary(
|
||||||
widget.petId,
|
widget.petId,
|
||||||
|
tz: tzOffsetQueryValue(DateTime.now().timeZoneOffset),
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -121,6 +133,27 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 待办提醒(?status=pending,due_at ASC):驱动「健康提醒」卡与
|
||||||
|
/// 提醒入口副行——demo 时代的硬编码提醒文案自此为真实数据取代。
|
||||||
|
/// 失败只降级为入口副行提示,不阻塞档案主链路。
|
||||||
|
Future<void> _loadPendingReminders() async {
|
||||||
|
setState(() => _remindersPhase = _RemindersPhase.loading);
|
||||||
|
try {
|
||||||
|
final reminders = await widget.controller.repository.listCareReminders(
|
||||||
|
widget.petId,
|
||||||
|
status: CareReminderStatus.pending,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pendingReminders = reminders;
|
||||||
|
_remindersPhase = _RemindersPhase.ready;
|
||||||
|
});
|
||||||
|
} on ApiException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _remindersPhase = _RemindersPhase.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _openEdit() async {
|
Future<void> _openEdit() async {
|
||||||
final pet = _pet;
|
final pet = _pet;
|
||||||
if (pet == null) return;
|
if (pet == null) return;
|
||||||
@@ -185,6 +218,36 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
if (mounted) await _loadSummary();
|
if (mounted) await _loadSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openTimeline(Pet pet) async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
fadePageRoute(
|
||||||
|
HealthEventsPage(
|
||||||
|
repository: widget.controller.repository,
|
||||||
|
petId: pet.id,
|
||||||
|
canWrite: _canWriteRecords,
|
||||||
|
analytics: widget.healthAnalytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// 事件可能已变化:返回即重拉摘要(月度花费实时聚合)。
|
||||||
|
if (mounted) await _loadSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openReminders(Pet pet) async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
fadePageRoute(
|
||||||
|
CareRemindersPage(
|
||||||
|
repository: widget.controller.repository,
|
||||||
|
petId: pet.id,
|
||||||
|
canWrite: _canWriteRecords,
|
||||||
|
analytics: widget.healthAnalytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// 待办可能已变化(完成/忽略/新建):返回即重拉待办。
|
||||||
|
if (mounted) await _loadPendingReminders();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -271,6 +334,8 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
Text('健康数据', style: Theme.of(context).textTheme.titleLarge),
|
Text('健康数据', style: Theme.of(context).textTheme.titleLarge),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_summarySection(pet),
|
_summarySection(pet),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_recordsSection(pet),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text('基本资料', style: Theme.of(context).textTheme.titleLarge),
|
Text('基本资料', style: Theme.of(context).textTheme.titleLarge),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
@@ -316,7 +381,76 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 数据卡行(05 §4.2 stat-row):三卡取数全部来自 summary 实时聚合,
|
/// 记录导航区:健康时间线与照护提醒入口(T2-14)。待办提醒非空时
|
||||||
|
/// 上方渲染真实数据驱动的「健康提醒」卡(正典 alert-card 形态,
|
||||||
|
/// 取代 demo 硬编码文案),点卡与点入口同去提醒页。
|
||||||
|
Widget _recordsSection(Pet pet) {
|
||||||
|
final nearest = _pendingReminders.isEmpty ? null : _pendingReminders.first;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (nearest != null) ...[
|
||||||
|
_ReminderAlertCard(
|
||||||
|
reminder: nearest,
|
||||||
|
overdue: isReminderOverdue(nearest, DateTime.now()),
|
||||||
|
onTap: () => _openReminders(pet),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
SectionCard(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.event_note_outlined,
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
title: const Text('健康时间线', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: const Text(
|
||||||
|
'就医 · 喂养 · 驱虫 · 洗护 · 测量 · 随手记',
|
||||||
|
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
onTap: () => _openTimeline(pet),
|
||||||
|
),
|
||||||
|
const Divider(height: 1, thickness: 1, color: AppColors.border),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.notifications_outlined,
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
title: const Text('照护提醒', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
switch (_remindersPhase) {
|
||||||
|
_RemindersPhase.loading => '加载中…',
|
||||||
|
_RemindersPhase.error => '提醒加载失败,点击查看',
|
||||||
|
_RemindersPhase.ready when _pendingReminders.isEmpty =>
|
||||||
|
'暂无待办提醒',
|
||||||
|
_RemindersPhase.ready => '${_pendingReminders.length} 条待办',
|
||||||
|
},
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
onTap: () => _openReminders(pet),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 数据卡行(05 §4.2 stat-row):四卡取数全部来自 summary 实时聚合,
|
||||||
/// 不落任何本地展示字符串(第 4.3 节红线)。null 语义 → 空态文案。
|
/// 不落任何本地展示字符串(第 4.3 节红线)。null 语义 → 空态文案。
|
||||||
Widget _summarySection(Pet pet) {
|
Widget _summarySection(Pet pet) {
|
||||||
switch (_summaryPhase) {
|
switch (_summaryPhase) {
|
||||||
@@ -348,13 +482,15 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
final weight = summary.latestWeight;
|
final weight = summary.latestWeight;
|
||||||
final progress = summary.vaccinationProgress;
|
final progress = summary.vaccinationProgress;
|
||||||
final next = summary.nextVaccination;
|
final next = summary.nextVaccination;
|
||||||
|
final expense = summary.monthlyExpense;
|
||||||
return IntrinsicHeight(
|
return IntrinsicHeight(
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SummaryCard(
|
child: _SummaryCard(
|
||||||
type: RecordType.weight,
|
icon: recordTypeStyles[RecordType.weight]!.icon,
|
||||||
|
iconColor: recordTypeStyles[RecordType.weight]!.iconColor,
|
||||||
value: weight == null
|
value: weight == null
|
||||||
? '暂无记录'
|
? '暂无记录'
|
||||||
: '${formatWeightKg(weight.weightKg)} kg',
|
: '${formatWeightKg(weight.weightKg)} kg',
|
||||||
@@ -366,7 +502,8 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SummaryCard(
|
child: _SummaryCard(
|
||||||
type: RecordType.vaccine,
|
icon: recordTypeStyles[RecordType.vaccine]!.icon,
|
||||||
|
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
|
||||||
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
|
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
|
||||||
value: progress == null
|
value: progress == null
|
||||||
? '未登记'
|
? '未登记'
|
||||||
@@ -379,13 +516,26 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SummaryCard(
|
child: _SummaryCard(
|
||||||
type: RecordType.vaccine,
|
icon: recordTypeStyles[RecordType.vaccine]!.icon,
|
||||||
|
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
|
||||||
value: next == null ? '暂无安排' : dateToJson(next.dueOn),
|
value: next == null ? '暂无安排' : dateToJson(next.dueOn),
|
||||||
emphasized: next != null,
|
emphasized: next != null,
|
||||||
label: next == null ? '下一针' : '下一针·${next.vaccineName}',
|
label: next == null ? '下一针' : '下一针·${next.vaccineName}',
|
||||||
onTap: () => _openVaccinations(pet),
|
onTap: () => _openVaccinations(pet),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryCard(
|
||||||
|
icon: Icons.payments_outlined,
|
||||||
|
iconColor: AppColors.primaryStrong,
|
||||||
|
// monthlyExpense 恒非 null(契约);金额整数分 → 元展示。
|
||||||
|
value: '¥${formatCentsAsYuan(expense.amountCents)}',
|
||||||
|
emphasized: expense.amountCents > 0,
|
||||||
|
label: '本月花费',
|
||||||
|
onTap: () => _openTimeline(pet),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -393,18 +543,20 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 数据卡(正典 stat-card 形态):类型图标 + 数值 15/w800 + 标签 12 inkSoft;
|
/// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft;
|
||||||
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
|
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
|
||||||
class _SummaryCard extends StatelessWidget {
|
class _SummaryCard extends StatelessWidget {
|
||||||
const _SummaryCard({
|
const _SummaryCard({
|
||||||
required this.type,
|
required this.icon,
|
||||||
|
required this.iconColor,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.emphasized,
|
required this.emphasized,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final RecordType type;
|
final IconData icon;
|
||||||
|
final Color iconColor;
|
||||||
final String value;
|
final String value;
|
||||||
final String label;
|
final String label;
|
||||||
final bool emphasized;
|
final bool emphasized;
|
||||||
@@ -412,7 +564,6 @@ class _SummaryCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final style = recordTypeStyles[type]!;
|
|
||||||
return Card(
|
return Card(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@@ -422,7 +573,7 @@ class _SummaryCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(style.icon, size: 18, color: style.iconColor),
|
Icon(icon, size: 18, color: iconColor),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
@@ -487,3 +638,62 @@ class _RowDivider extends StatelessWidget {
|
|||||||
return const Divider(height: 1, thickness: 1, color: AppColors.border);
|
return const Divider(height: 1, thickness: 1, color: AppColors.border);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 「健康提醒」卡(正典 alert-card:successSurface 底 + dot + 文字):
|
||||||
|
/// 数据源为最近到期的待办提醒(真实数据驱动,取代 demo 硬编码文案);
|
||||||
|
/// 逾期时文案切警示深色(双通道:前缀文字 + 颜色)。
|
||||||
|
class _ReminderAlertCard extends StatelessWidget {
|
||||||
|
const _ReminderAlertCard({
|
||||||
|
required this.reminder,
|
||||||
|
required this.overdue,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CareReminder reminder;
|
||||||
|
final bool overdue;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final textColor = overdue ? AppColors.errorDark : AppColors.successInk;
|
||||||
|
return Material(
|
||||||
|
color: AppColors.successSurface,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: textColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'健康提醒:${reminder.title}'
|
||||||
|
'(${overdue ? '已逾期' : '${dateToJson(reminder.dueAt.toLocal())} 到期'})',
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.chevron_right, size: 18, color: textColor),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
export 'package:patbond_flutter/core/models/cursor_page.dart';
|
||||||
|
|
||||||
/// 物种(创建即定,不可修改)。
|
/// 物种(创建即定,不可修改)。
|
||||||
enum PetSpecies {
|
enum PetSpecies {
|
||||||
dog,
|
dog,
|
||||||
@@ -124,33 +126,6 @@ String dateToJson(DateTime date) {
|
|||||||
DateTime? _dateOrNull(Object? value) =>
|
DateTime? _dateOrNull(Object? value) =>
|
||||||
value == null ? null : DateTime.parse(value as String);
|
value == null ? null : DateTime.parse(value as String);
|
||||||
|
|
||||||
/// cursor 分页正典信封 `{items, nextCursor, hasMore}`(体重、健康事件)。
|
|
||||||
class CursorPage<T> {
|
|
||||||
const CursorPage({
|
|
||||||
required this.items,
|
|
||||||
required this.nextCursor,
|
|
||||||
required this.hasMore,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory CursorPage.fromJson(
|
|
||||||
Map<String, dynamic> json,
|
|
||||||
T Function(Map<String, dynamic>) itemFromJson,
|
|
||||||
) {
|
|
||||||
return CursorPage(
|
|
||||||
items: (json['items'] as List)
|
|
||||||
.map((item) => itemFromJson(item as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
// 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。
|
|
||||||
nextCursor: json['nextCursor'] as String?,
|
|
||||||
hasMore: json['hasMore'] as bool,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<T> items;
|
|
||||||
final String? nextCursor;
|
|
||||||
final bool hasMore;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
|
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
|
||||||
/// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。
|
/// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。
|
||||||
class Pet {
|
class Pet {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
|||||||
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
|
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
|
||||||
@@ -21,6 +23,10 @@ enum _ListPhase { loading, ready, error }
|
|||||||
/// 含 cancelled 行原样展示(取消后同剂次可重新登记的事实留痕)。
|
/// 含 cancelled 行原样展示(取消后同剂次可重新登记的事实留痕)。
|
||||||
/// 四态齐备;登记经 [VaccinationFormPage]。
|
/// 四态齐备;登记经 [VaccinationFormPage]。
|
||||||
///
|
///
|
||||||
|
/// T2-14 收尾(25 号报告 §7 遗留①②):scheduled 行支持「标记完成 /
|
||||||
|
/// 取消登记」PATCH 流转;完成时可补录厂商/批号(契约可选字段);
|
||||||
|
/// 挂 `health_record_edit_succeeded/failed`(recordType=vaccine)。
|
||||||
|
///
|
||||||
/// 曝光埋点:每次进入首个成功加载上报一次
|
/// 曝光埋点:每次进入首个成功加载上报一次
|
||||||
/// `health_record_viewed(recordType=vaccine, source=pet_detail)`。
|
/// `health_record_viewed(recordType=vaccine, source=pet_detail)`。
|
||||||
class VaccinationRecordsPage extends StatefulWidget {
|
class VaccinationRecordsPage extends StatefulWidget {
|
||||||
@@ -53,6 +59,7 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
|||||||
List<Vaccination> _records = const [];
|
List<Vaccination> _records = const [];
|
||||||
ApiException? _error;
|
ApiException? _error;
|
||||||
bool _viewedFired = false;
|
bool _viewedFired = false;
|
||||||
|
bool _mutating = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -109,6 +116,124 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _trackEditFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.editFailed(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
reason: reason,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记完成(25 号报告遗留①②):接种日期必填、下次接种/厂商/批号可选
|
||||||
|
/// (厂商/批号为契约可选字段的补录入口)。
|
||||||
|
Future<void> _markCompleted(Vaccination record) async {
|
||||||
|
final result = await showDialog<_CompleteVaccinationResult>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _CompleteVaccinationDialog(record: record),
|
||||||
|
);
|
||||||
|
if (result == null || !mounted) return;
|
||||||
|
await _mutate(
|
||||||
|
record,
|
||||||
|
UpdateVaccinationRequest(
|
||||||
|
version: record.version,
|
||||||
|
status: VaccinationStatus.completed,
|
||||||
|
administeredOn: result.administeredOn,
|
||||||
|
nextDueOn: result.nextDueOn,
|
||||||
|
manufacturer: result.manufacturer,
|
||||||
|
batchNo: result.batchNo,
|
||||||
|
),
|
||||||
|
successText: '已标记完成',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelRegistration(Vaccination record) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('取消这条登记?'),
|
||||||
|
content: Text(
|
||||||
|
'「${vaccinationDoseLabel(record)}」将标记为已取消;'
|
||||||
|
'取消后同系列同剂次可重新登记。',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('返回'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('取消登记'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
await _mutate(
|
||||||
|
record,
|
||||||
|
UpdateVaccinationRequest(
|
||||||
|
version: record.version,
|
||||||
|
status: VaccinationStatus.cancelled,
|
||||||
|
),
|
||||||
|
successText: '已取消登记',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _mutate(
|
||||||
|
Vaccination record,
|
||||||
|
UpdateVaccinationRequest request, {
|
||||||
|
required String successText,
|
||||||
|
}) async {
|
||||||
|
if (_mutating) return;
|
||||||
|
setState(() => _mutating = true);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
try {
|
||||||
|
await widget.repository.updateVaccination(record.id, request);
|
||||||
|
widget.analytics?.editSucceeded(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
fieldCount: request.toJson().length - 1,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(successText)));
|
||||||
|
await _load();
|
||||||
|
} on PetVersionConflictException {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 40902:并发修改抢先——刷新取新 version 后由用户重试动作。
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('记录已在其他设备被修改,已刷新,请重试')),
|
||||||
|
);
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.conflict, 40902);
|
||||||
|
await _load();
|
||||||
|
} on VaccinationRuleException {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 42201:状态机/状态-日期规则兜底(前端已按规则拦截主路径)。
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('接种状态与日期不符合规则,请核对后重试')),
|
||||||
|
);
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.validationError, 42201);
|
||||||
|
} on PetRecordNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('记录不存在或已被删除,已刷新')));
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.notFound, 40402);
|
||||||
|
await _load();
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该记录')));
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.serverError, error.code);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _mutating = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -187,7 +312,24 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
children
|
children
|
||||||
..add(_VaccinationTile(record: record))
|
..add(
|
||||||
|
_VaccinationTile(
|
||||||
|
record: record,
|
||||||
|
// 流转动作仅 scheduled 行可用(终态由服务端状态机守卫)。
|
||||||
|
onComplete:
|
||||||
|
widget.canWrite &&
|
||||||
|
record.status == VaccinationStatus.scheduled &&
|
||||||
|
!_mutating
|
||||||
|
? () => _markCompleted(record)
|
||||||
|
: null,
|
||||||
|
onCancel:
|
||||||
|
widget.canWrite &&
|
||||||
|
record.status == VaccinationStatus.scheduled &&
|
||||||
|
!_mutating
|
||||||
|
? () => _cancelRegistration(record)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
..add(const SizedBox(height: 10));
|
..add(const SizedBox(height: 10));
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
@@ -201,18 +343,28 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 疫苗条目:RecordTypeDot(疫苗) + 剂次标题 + 日期副行 + 状态 TagPill
|
/// 疫苗条目:RecordTypeDot(疫苗) + 剂次标题 + 日期副行 + 状态 TagPill
|
||||||
/// (图标+文字双通道,不单靠颜色区分)。
|
/// (图标+文字双通道,不单靠颜色区分);scheduled 行附
|
||||||
|
/// 「标记完成 / 取消登记」流转动作。
|
||||||
class _VaccinationTile extends StatelessWidget {
|
class _VaccinationTile extends StatelessWidget {
|
||||||
const _VaccinationTile({required this.record});
|
const _VaccinationTile({
|
||||||
|
required this.record,
|
||||||
|
this.onComplete,
|
||||||
|
this.onCancel,
|
||||||
|
});
|
||||||
|
|
||||||
final Vaccination record;
|
final Vaccination record;
|
||||||
|
final VoidCallback? onComplete;
|
||||||
|
final VoidCallback? onCancel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const RecordTypeDot(
|
const RecordTypeDot(
|
||||||
type: RecordType.vaccine,
|
type: RecordType.vaccine,
|
||||||
@@ -247,7 +399,196 @@ class _VaccinationTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (onComplete != null || onCancel != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onCancel,
|
||||||
|
icon: const Icon(Icons.close, size: 16),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
label: const Text('取消登记'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onComplete,
|
||||||
|
icon: const Icon(Icons.check_circle_outline, size: 16),
|
||||||
|
label: const Text('标记完成'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 标记完成对话框返回值。
|
||||||
|
class _CompleteVaccinationResult {
|
||||||
|
const _CompleteVaccinationResult({
|
||||||
|
required this.administeredOn,
|
||||||
|
this.nextDueOn,
|
||||||
|
this.manufacturer,
|
||||||
|
this.batchNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime administeredOn;
|
||||||
|
final DateTime? nextDueOn;
|
||||||
|
final String? manufacturer;
|
||||||
|
final String? batchNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记完成对话框:接种日期必填(默认今天)、下次接种可选;
|
||||||
|
/// 厂商/批号补录(契约可选字段,25 号报告遗留②的落地入口)。
|
||||||
|
/// 日期规则复用 [vaccinationDateRuleError](42201 前置拦截)。
|
||||||
|
class _CompleteVaccinationDialog extends StatefulWidget {
|
||||||
|
const _CompleteVaccinationDialog({required this.record});
|
||||||
|
|
||||||
|
final Vaccination record;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CompleteVaccinationDialog> createState() =>
|
||||||
|
_CompleteVaccinationDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompleteVaccinationDialogState
|
||||||
|
extends State<_CompleteVaccinationDialog> {
|
||||||
|
final _manufacturerCtrl = TextEditingController();
|
||||||
|
final _batchNoCtrl = TextEditingController();
|
||||||
|
DateTime _administeredOn = DateTime.now();
|
||||||
|
DateTime? _nextDueOn;
|
||||||
|
String? _dateError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_manufacturerCtrl.text = widget.record.manufacturer ?? '';
|
||||||
|
_batchNoCtrl.text = widget.record.batchNo ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_manufacturerCtrl.dispose();
|
||||||
|
_batchNoCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirm() {
|
||||||
|
final error = vaccinationDateRuleError(
|
||||||
|
status: VaccinationStatus.completed,
|
||||||
|
administeredOn: _administeredOn,
|
||||||
|
nextDueOn: _nextDueOn,
|
||||||
|
);
|
||||||
|
if (error != null) {
|
||||||
|
setState(() => _dateError = error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final manufacturer = _manufacturerCtrl.text.trim();
|
||||||
|
final batchNo = _batchNoCtrl.text.trim();
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
_CompleteVaccinationResult(
|
||||||
|
administeredOn: _administeredOn,
|
||||||
|
nextDueOn: _nextDueOn,
|
||||||
|
manufacturer: manufacturer.isEmpty ? null : manufacturer,
|
||||||
|
batchNo: batchNo.isEmpty ? null : batchNo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dateTile({
|
||||||
|
required String label,
|
||||||
|
required DateTime? value,
|
||||||
|
required bool allowFuture,
|
||||||
|
required ValueChanged<DateTime> onPicked,
|
||||||
|
}) {
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||||
|
title: Text(label, style: const TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
value == null ? '未选择' : dateToJson(value),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: value ?? now,
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: allowFuture ? DateTime(now.year + 5) : now,
|
||||||
|
);
|
||||||
|
if (picked != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
onPicked(picked);
|
||||||
|
_dateError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('标记完成'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'「${vaccinationDoseLabel(widget.record)}」',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_dateTile(
|
||||||
|
label: '接种日期',
|
||||||
|
value: _administeredOn,
|
||||||
|
allowFuture: false,
|
||||||
|
onPicked: (value) => _administeredOn = value,
|
||||||
|
),
|
||||||
|
_dateTile(
|
||||||
|
label: '下次接种日期(可选)',
|
||||||
|
value: _nextDueOn,
|
||||||
|
allowFuture: true,
|
||||||
|
onPicked: (value) => _nextDueOn = value,
|
||||||
|
),
|
||||||
|
if (_dateError != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_dateError!,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppTextField(
|
||||||
|
label: '厂商(可选)',
|
||||||
|
controller: _manufacturerCtrl,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppTextField(
|
||||||
|
label: '批号(可选)',
|
||||||
|
controller: _batchNoCtrl,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _confirm(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(onPressed: _confirm, child: const Text('确认完成')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -96,7 +96,10 @@ class ProfilePage extends StatelessWidget {
|
|||||||
const _ProfileStat(value: '24', label: '关注我'),
|
const _ProfileStat(value: '24', label: '关注我'),
|
||||||
const _ProfileStat(value: '1.8k', label: '获赞'),
|
const _ProfileStat(value: '1.8k', label: '获赞'),
|
||||||
_ProfileStat(
|
_ProfileStat(
|
||||||
value: '${appState.posts.length}',
|
// 「我的资料」头部整体仍是 demo 家具(M5 范围):三项
|
||||||
|
// 统计同源 demo 常量;AppState.posts 随 T3-17 退役后
|
||||||
|
// 本项直读 demo 列表长度,不伪装真实数据。
|
||||||
|
value: '${initialPosts.length}',
|
||||||
label: '我的作品',
|
label: '我的作品',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
class AppState extends ChangeNotifier {
|
class AppState extends ChangeNotifier {
|
||||||
static const _petKey = 'patbond_pet';
|
static const _petKey = 'patbond_pet';
|
||||||
static const _postsKey = 'patbond_posts';
|
|
||||||
static const _locationWeatherKey = 'patbond_location_weather';
|
static const _locationWeatherKey = 'patbond_location_weather';
|
||||||
|
|
||||||
/// 首页问候卡 / 创作页 / 主壳头像仍消费的 demo 宠物(T2-12 起档案
|
/// 首页问候卡 / 创作页 / 主壳头像仍消费的 demo 宠物(T2-12 起档案
|
||||||
/// Tab 已切独立 pets feature 真实数据;此 demo 随后续工单收敛)。
|
/// Tab 已切独立 pets feature 真实数据;此 demo 随后续工单收敛)。
|
||||||
|
///
|
||||||
|
/// **demo 帖子列表已于 T3-17 退役**:Feed / 详情页自 T3-14/15 起消费
|
||||||
|
/// community 真实数据,发布页自 T3-17 起走真实两步上传 + 建草稿/迁移
|
||||||
|
/// 发布,`posts` / `publishPost` / `updatePost` 与其
|
||||||
|
/// shared_preferences 持久化一并删除(03 号评估 §1.1 判定)。
|
||||||
PetProfile pet = initialPet;
|
PetProfile pet = initialPet;
|
||||||
List<PostModel> posts = List<PostModel>.from(initialPosts);
|
|
||||||
LocationWeather locationWeather = initialLocationWeather;
|
LocationWeather locationWeather = initialLocationWeather;
|
||||||
bool isReady = false;
|
bool isReady = false;
|
||||||
|
|
||||||
@@ -21,17 +24,11 @@ class AppState extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
final savedPet = preferences.getString(_petKey);
|
final savedPet = preferences.getString(_petKey);
|
||||||
final savedPosts = preferences.getString(_postsKey);
|
|
||||||
final savedLocationWeather = preferences.getString(_locationWeatherKey);
|
final savedLocationWeather = preferences.getString(_locationWeatherKey);
|
||||||
|
|
||||||
if (savedPet != null) {
|
if (savedPet != null) {
|
||||||
pet = PetProfile.fromJson(jsonDecode(savedPet) as Map<String, dynamic>);
|
pet = PetProfile.fromJson(jsonDecode(savedPet) as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
if (savedPosts != null) {
|
|
||||||
posts = (jsonDecode(savedPosts) as List)
|
|
||||||
.map((item) => PostModel.fromJson(item as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
if (savedLocationWeather != null) {
|
if (savedLocationWeather != null) {
|
||||||
locationWeather = LocationWeather.fromJson(
|
locationWeather = LocationWeather.fromJson(
|
||||||
jsonDecode(savedLocationWeather) as Map<String, dynamic>,
|
jsonDecode(savedLocationWeather) as Map<String, dynamic>,
|
||||||
@@ -40,7 +37,6 @@ class AppState extends ChangeNotifier {
|
|||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
|
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
|
||||||
pet = initialPet;
|
pet = initialPet;
|
||||||
posts = List<PostModel>.from(initialPosts);
|
|
||||||
locationWeather = initialLocationWeather;
|
locationWeather = initialLocationWeather;
|
||||||
} finally {
|
} finally {
|
||||||
isReady = true;
|
isReady = true;
|
||||||
@@ -48,21 +44,6 @@ class AppState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updatePost(PostModel value) async {
|
|
||||||
final index = posts.indexWhere((post) => post.id == value.id);
|
|
||||||
if (index == -1) return;
|
|
||||||
posts[index] = value;
|
|
||||||
posts = List<PostModel>.from(posts);
|
|
||||||
notifyListeners();
|
|
||||||
await _savePosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> publishPost(PostModel value) async {
|
|
||||||
posts = [value, ...posts];
|
|
||||||
notifyListeners();
|
|
||||||
await _savePosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateLocationWeather(LocationWeather value) async {
|
Future<void> updateLocationWeather(LocationWeather value) async {
|
||||||
locationWeather = value;
|
locationWeather = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -71,21 +52,15 @@ class AppState extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> resetDemoData() async {
|
Future<void> resetDemoData() async {
|
||||||
pet = initialPet;
|
pet = initialPet;
|
||||||
posts = List<PostModel>.from(initialPosts);
|
|
||||||
locationWeather = initialLocationWeather;
|
locationWeather = initialLocationWeather;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
preferences.remove(_petKey),
|
preferences.remove(_petKey),
|
||||||
preferences.remove(_postsKey),
|
|
||||||
preferences.remove(_locationWeatherKey),
|
preferences.remove(_locationWeatherKey),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _savePosts() {
|
|
||||||
return _save(_postsKey, posts.map((post) => post.toJson()).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _save(String key, Object value) async {
|
Future<void> _save(String key, Object value) async {
|
||||||
try {
|
try {
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/signed_network_image.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
|
||||||
class RemoteImage extends StatelessWidget {
|
class RemoteImage extends StatelessWidget {
|
||||||
@@ -21,8 +22,10 @@ class RemoteImage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
child: Image.network(
|
// 缓存 key 剥离预签名参数(T3-14):媒体 URL 每次响应现签,
|
||||||
url,
|
// 按完整 URL 缓存会同图重复下载。
|
||||||
|
child: Image(
|
||||||
|
image: SignedNetworkImage(url),
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
|
|||||||
@@ -6,9 +6,13 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||||
|
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_linux
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,15 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import file_selector_macos
|
||||||
|
import flutter_image_compress_macos
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
|
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
+200
-1
@@ -57,6 +57,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+5"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -90,7 +98,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.2"
|
version: "2.2.2"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: fake_async
|
name: fake_async
|
||||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
@@ -121,6 +129,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4+1"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5+1"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+6"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -134,6 +174,59 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_driver:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_image_compress:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress
|
||||||
|
sha256: "98a48c05a7add6869c6838270e862124a4d571f9dd5d0cf209ed03a71b20ea84"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.1"
|
||||||
|
flutter_image_compress_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress_common
|
||||||
|
sha256: "76869e4d5f3d65f3431e7edff0b2d8ad1eea68b49c6a37772fdb1ae6016da9ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
flutter_image_compress_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress_macos
|
||||||
|
sha256: "0d2a842d2e544828fb32bda16dcfccb3149107df568898a4936d78232e486847"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
flutter_image_compress_ohos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress_ohos
|
||||||
|
sha256: "1491bb7bcfdf59e3b127c263c116cfbf8ed3afade6e7fa691d9622e838ed7e48"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.3+1"
|
||||||
|
flutter_image_compress_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress_platform_interface
|
||||||
|
sha256: bbefb7967bda565004fdabdb3300dcbfb40c7ef8402675d23206e5bddc358178
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
flutter_image_compress_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress_web
|
||||||
|
sha256: "91cc58e091a1e09c7683d216d579e2b964119a8527fc43b64dfdb00f1acc94ec"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.5+1"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -142,6 +235,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.35"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -200,6 +301,11 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
fuchsia_remote_debug_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -224,6 +330,75 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: image_picker
|
||||||
|
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.3"
|
||||||
|
image_picker_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_android
|
||||||
|
sha256: "1c0c38790306fda4ed774095620444333e56a2b6bc8fc98f3a35c9398781cf54"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+22"
|
||||||
|
image_picker_for_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_for_web
|
||||||
|
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
|
image_picker_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_ios
|
||||||
|
sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+7"
|
||||||
|
image_picker_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_linux
|
||||||
|
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
image_picker_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_macos
|
||||||
|
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2+1"
|
||||||
|
image_picker_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_platform_interface
|
||||||
|
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.11.1"
|
||||||
|
image_picker_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_windows
|
||||||
|
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
integration_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
jni:
|
jni:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -424,6 +599,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
process:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: process
|
||||||
|
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.6"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -533,6 +716,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
sync_http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sync_http
|
||||||
|
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -589,6 +780,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
webdriver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webdriver
|
||||||
|
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -39,10 +39,19 @@ dependencies:
|
|||||||
flutter_secure_storage: ^11.0.0
|
flutter_secure_storage: ^11.0.0
|
||||||
uuid: ^4.6.0
|
uuid: ^4.6.0
|
||||||
package_info_plus: ^10.2.1
|
package_info_plus: ^10.2.1
|
||||||
|
# T3-13 媒体上传:系统选择器多选(03 号评估 §4.1 选型)。
|
||||||
|
image_picker: ^1.2.0
|
||||||
|
# T3-13 媒体上传:原生编解码压缩(长边重采样 + JPEG 质量 + EXIF 方向矫正)。
|
||||||
|
flutter_image_compress: ^2.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
# T3-14 compose 真链路桌面实测入口(integration_test/,环境变量门控)。
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
|
# 定时冲刷/退避测试的假时钟驱动(flutter_test 传递依赖显式声明)。
|
||||||
|
fake_async: ^1.3.3
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
|
|||||||
Executable
+104
@@ -0,0 +1,104 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# check-secrets.sh —— 凭证防泄漏检查(ADR-021,规范见 patbond-doc docs/development/git-workflow.md)
|
||||||
|
#
|
||||||
|
# 规则单一来源:本地 pre-commit 与 CI 兜底跑的是同一个脚本、同一张规则表。
|
||||||
|
# 三仓(patbond-api / patbond-flutter / patbond-doc)各存一份同构副本,改规则时三仓同步。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# sh scripts/check-secrets.sh --staged # pre-commit:扫暂存区内容(经 scripts/hooks/pre-commit 调用)
|
||||||
|
# sh scripts/check-secrets.sh --all # CI 兜底 / 手动自查:扫全部已跟踪文件(缺省模式)
|
||||||
|
# sh scripts/check-secrets.sh <文件...> # 扫指定文件
|
||||||
|
#
|
||||||
|
# 拦下真实凭证时的第一动作:去云控制台轮换/禁用该密钥,然后才是清理提交。
|
||||||
|
set -u
|
||||||
|
|
||||||
|
mode="${1:---all}"
|
||||||
|
|
||||||
|
# 允许清单:行内出现任一形态即放行(${} 注入、占位值、明显示例值)
|
||||||
|
ALLOW='\$\{[^}]*\}|\{\{[^}]*\}\}|changeme|change[-_]me|your[-_][a-zA-Z0-9_-]+|<[a-zA-Z0-9 ,_.-]+>|placeholder|example|sample|dummy|fake|redacted|\*\*\*'
|
||||||
|
|
||||||
|
# 内容扫描跳过:本脚本与 hook 自身(含规则文本,非凭证)
|
||||||
|
SKIP_PATHS='(^|/)scripts/(check-secrets\.sh|hooks/pre-commit)$'
|
||||||
|
|
||||||
|
# 文件名黑名单:凭证载体文件本体禁止入库(.sample/.example 除外)
|
||||||
|
DENY_NAME='(^|/)\.env(\.[^/]+)?$|(^|/)credentials[^/]*$|[Aa]ccess[Kk]eys?[^/]*\.csv$|(^|/)rootkey\.csv$'
|
||||||
|
DENY_NAME_OK='\.(sample|example)$'
|
||||||
|
|
||||||
|
# 规则表:ID<TAB>大小写旗标(i=忽略大小写,-=敏感)<TAB>文件范围ERE(-=全部文件)<TAB>行模式ERE
|
||||||
|
RULES=$(cat <<'EOF'
|
||||||
|
AK-AWS - - AKIA[0-9A-Z]{16}
|
||||||
|
AK-QCLOUD - - AKID[0-9A-Za-z]{16,}
|
||||||
|
AK-ALIYUN - - LTAI[0-9A-Za-z]{12,}
|
||||||
|
MINIO-DEFAULT i - minio[-_.]?admin
|
||||||
|
PRIVATE-KEY - - ^[[:space:]]*-----BEGIN [A-Z ]*PRIVATE KEY-----[[:space:]]*$
|
||||||
|
KEY-ASSIGN i - (access[-_]?key(_?id)?|secret[-_]?(access[-_]?)?key)["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
|
||||||
|
JWT-SECRET i - (jwt[-_.]?secret|signing[-_]?key|token[-_]?secret|hmac[-_]?(key|secret))["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
|
||||||
|
DB-PASSWORD i \.(ya?ml|properties|toml|conf|ini)(\.sample|\.example)?$ (password|passwd|pwd)["']?[[:space:]]*[:=][[:space:]]*["']?[^[:space:]"'$]{6,}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
--staged)
|
||||||
|
files=$(git diff --cached --name-only --diff-filter=ACM)
|
||||||
|
src=index
|
||||||
|
;;
|
||||||
|
--all)
|
||||||
|
files=$(git ls-files)
|
||||||
|
src=worktree
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "用法: $0 [--staged|--all|<文件...>]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
files=$(printf '%s\n' "$@")
|
||||||
|
src=worktree
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[ -n "$files" ] || exit 0
|
||||||
|
|
||||||
|
tmp=$(mktemp) || exit 2
|
||||||
|
viol=$(mktemp) || exit 2
|
||||||
|
trap 'rm -f "$tmp" "$viol"' EXIT
|
||||||
|
|
||||||
|
# 第一道:文件名黑名单
|
||||||
|
printf '%s\n' "$files" | grep -E "$DENY_NAME" | grep -vE "$DENY_NAME_OK" |
|
||||||
|
sed 's/^/[NAME-DENY] /' >>"$viol" || true
|
||||||
|
|
||||||
|
# 第二道:逐文件逐规则内容扫描(二进制文件经 grep -I 自然跳过)
|
||||||
|
IFS='
|
||||||
|
'
|
||||||
|
for f in $files; do
|
||||||
|
printf '%s' "$f" | grep -qE "$SKIP_PATHS" && continue
|
||||||
|
if [ "$src" = index ]; then
|
||||||
|
git show ":$f" >"$tmp" 2>/dev/null || continue
|
||||||
|
else
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
cat -- "$f" >"$tmp"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$RULES" | while IFS="$(printf '\t')" read -r id flag scope pat; do
|
||||||
|
[ -n "$id" ] || continue
|
||||||
|
if [ "$scope" != "-" ]; then
|
||||||
|
printf '%s' "$f" | grep -qE "$scope" || continue
|
||||||
|
fi
|
||||||
|
ci=""
|
||||||
|
[ "$flag" = "i" ] && ci="-i"
|
||||||
|
grep -InE $ci -e "$pat" "$tmp" 2>/dev/null | grep -viE "$ALLOW" |
|
||||||
|
sed "s|^|[$id] $f:|" >>"$viol" || true
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -s "$viol" ]; then
|
||||||
|
echo "凭证防泄漏检查未通过(ADR-021)——以下内容疑似真实凭证:" >&2
|
||||||
|
cat "$viol" >&2
|
||||||
|
cat >&2 <<'MSG'
|
||||||
|
处置:
|
||||||
|
1. 若是真实凭证:先去云控制台轮换/禁用该密钥,再从提交中移除;
|
||||||
|
2. 若是误报:改用 ${} 注入或占位值(changeme / your-xxx / <占位>),
|
||||||
|
或与团队确认后调整三仓同构的 scripts/check-secrets.sh 规则表。
|
||||||
|
敏感信息只允许存在于被 gitignore 的文件或 .sample 占位中(git-workflow.md)。
|
||||||
|
MSG
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# pre-commit —— 凭证防泄漏(ADR-021)。启用(每人每仓一次):
|
||||||
|
# git config core.hooksPath scripts/hooks
|
||||||
|
# 注意:core.hooksPath 会整体接管 hooks 目录;本仓无其他自定义 hook。
|
||||||
|
repo_root=$(git rev-parse --show-toplevel) || exit 1
|
||||||
|
exec sh "$repo_root/scripts/check-secrets.sh" --staged
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import 'package:fake_async/fake_async.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_service.dart';
|
||||||
|
|
||||||
|
/// 定时冲刷与失败退避测试(M3 T3-19,13 号 §3.4 第 4 触发点 +
|
||||||
|
/// iteration-3 06 号 §2.3):fakeAsync 驱动 Timer.periodic,
|
||||||
|
/// 时钟注入照 SessionTracker 先例,假上传子类免起真实 HttpServer。
|
||||||
|
class _FakeUploadService extends AnalyticsService {
|
||||||
|
_FakeUploadService({super.now})
|
||||||
|
: super(
|
||||||
|
apiBaseUrl: 'http://unused',
|
||||||
|
getAccessToken: null,
|
||||||
|
getSessionId: () => 'session-x',
|
||||||
|
);
|
||||||
|
|
||||||
|
int uploadAttempts = 0;
|
||||||
|
bool failUploads = false;
|
||||||
|
final List<int> uploadedBatchSizes = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> uploadBatch(List<Map<String, dynamic>> events) async {
|
||||||
|
uploadAttempts++;
|
||||||
|
if (failUploads) {
|
||||||
|
throw Exception('simulated network failure');
|
||||||
|
}
|
||||||
|
uploadedBatchSizes.add(events.length);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
/// 逐秒推进假时间轴并同步注入时钟,保证定时器回调读到的 now 与
|
||||||
|
/// 已流逝时间一致(整段 elapse 会让回调读到未更新的旧时钟)。
|
||||||
|
(void Function(Duration), _FakeUploadService) setup(FakeAsync async) {
|
||||||
|
var clock = DateTime.utc(2026, 9, 8, 10);
|
||||||
|
final service = _FakeUploadService(now: () => clock);
|
||||||
|
void elapse(Duration duration) {
|
||||||
|
final target = clock.add(duration);
|
||||||
|
while (clock.isBefore(target)) {
|
||||||
|
clock = clock.add(const Duration(seconds: 1));
|
||||||
|
async.elapse(const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (elapse, service);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('AnalyticsService 定时冲刷', () {
|
||||||
|
test('前台每 30 秒冲刷不满 20 条的队列', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final (elapse, service) = setup(async);
|
||||||
|
service.startPeriodicFlush();
|
||||||
|
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
elapse(const Duration(seconds: 29));
|
||||||
|
expect(service.uploadAttempts, 0);
|
||||||
|
|
||||||
|
elapse(const Duration(seconds: 1));
|
||||||
|
expect(service.uploadedBatchSizes, [1]);
|
||||||
|
expect(service.pendingEvents, isEmpty);
|
||||||
|
service.stopPeriodicFlush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('队列为空时定时器不发起上传', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final (elapse, service) = setup(async);
|
||||||
|
service.startPeriodicFlush();
|
||||||
|
|
||||||
|
elapse(const Duration(minutes: 2));
|
||||||
|
expect(service.uploadAttempts, 0);
|
||||||
|
service.stopPeriodicFlush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stopPeriodicFlush 停止触发(退后台),start 恢复(回前台)', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final (elapse, service) = setup(async);
|
||||||
|
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
service.startPeriodicFlush();
|
||||||
|
service.stopPeriodicFlush();
|
||||||
|
elapse(const Duration(minutes: 2));
|
||||||
|
expect(service.uploadAttempts, 0);
|
||||||
|
|
||||||
|
service.startPeriodicFlush();
|
||||||
|
elapse(const Duration(seconds: 30));
|
||||||
|
expect(service.uploadedBatchSizes, [1]);
|
||||||
|
service.stopPeriodicFlush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startPeriodicFlush 幂等,不叠加多个定时器', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final (elapse, service) = setup(async);
|
||||||
|
service.startPeriodicFlush();
|
||||||
|
service.startPeriodicFlush();
|
||||||
|
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
elapse(const Duration(seconds: 30));
|
||||||
|
expect(service.uploadAttempts, 1);
|
||||||
|
service.stopPeriodicFlush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AnalyticsService 失败退避', () {
|
||||||
|
test('上传失败按 30s→60s→120s 指数退避,期间定时冲刷跳过', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final (elapse, service) = setup(async);
|
||||||
|
service.failUploads = true;
|
||||||
|
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||||||
|
async.flushMicrotasks();
|
||||||
|
service.startPeriodicFlush();
|
||||||
|
|
||||||
|
// t=30s 首次尝试失败 → 退避 30s(下次可试 t=60s)。
|
||||||
|
elapse(const Duration(seconds: 30));
|
||||||
|
expect(service.uploadAttempts, 1);
|
||||||
|
|
||||||
|
// t=60s 第二次失败 → 退避 60s(下次 t=120s);t=90s 被跳过。
|
||||||
|
elapse(const Duration(seconds: 30));
|
||||||
|
expect(service.uploadAttempts, 2);
|
||||||
|
elapse(const Duration(seconds: 30));
|
||||||
|
expect(service.uploadAttempts, 2);
|
||||||
|
|
||||||
|
// t=120s 第三次失败 → 退避 120s;t=150/180/210s 均跳过。
|
||||||
|
elapse(const Duration(seconds: 30));
|
||||||
|
expect(service.uploadAttempts, 3);
|
||||||
|
elapse(const Duration(seconds: 90));
|
||||||
|
expect(service.uploadAttempts, 3);
|
||||||
|
|
||||||
|
// t=240s 第四次尝试;事件始终保留在队列。
|
||||||
|
elapse(const Duration(seconds: 30));
|
||||||
|
expect(service.uploadAttempts, 4);
|
||||||
|
expect(service.pendingEvents.length, 1);
|
||||||
|
service.stopPeriodicFlush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('退避封顶 5 分钟', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final (elapse, service) = setup(async);
|
||||||
|
service.failUploads = true;
|
||||||
|
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||||||
|
async.flushMicrotasks();
|
||||||
|
service.startPeriodicFlush();
|
||||||
|
|
||||||
|
// 失败序列 t=30/60/120/240s(退避 30/60/120/240s),
|
||||||
|
// 第五次 t=480s:240s×2=480s 超帽,封顶 300s。
|
||||||
|
elapse(const Duration(minutes: 8));
|
||||||
|
expect(service.uploadAttempts, 5);
|
||||||
|
|
||||||
|
// t=780s 前(480+300s 窗口内)不再尝试,到点第六次。
|
||||||
|
elapse(const Duration(seconds: 299));
|
||||||
|
expect(service.uploadAttempts, 5);
|
||||||
|
elapse(const Duration(seconds: 1));
|
||||||
|
expect(service.uploadAttempts, 6);
|
||||||
|
service.stopPeriodicFlush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('退避只挡定时冲刷,flushNow 显式触发不受限', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final (elapse, service) = setup(async);
|
||||||
|
service.failUploads = true;
|
||||||
|
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||||||
|
async.flushMicrotasks();
|
||||||
|
service.startPeriodicFlush();
|
||||||
|
|
||||||
|
elapse(const Duration(seconds: 30));
|
||||||
|
expect(service.uploadAttempts, 1);
|
||||||
|
|
||||||
|
// 退避窗口内(t=45s)退后台显式冲刷仍然尝试。
|
||||||
|
elapse(const Duration(seconds: 15));
|
||||||
|
service.flushNow();
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(service.uploadAttempts, 2);
|
||||||
|
service.stopPeriodicFlush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('上传成功即重置退避,恢复 30 秒节奏', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final (elapse, service) = setup(async);
|
||||||
|
service.failUploads = true;
|
||||||
|
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||||||
|
async.flushMicrotasks();
|
||||||
|
service.startPeriodicFlush();
|
||||||
|
|
||||||
|
// t=30/60s 两次失败后网络恢复,t=120s 第三次成功。
|
||||||
|
elapse(const Duration(seconds: 60));
|
||||||
|
expect(service.uploadAttempts, 2);
|
||||||
|
service.failUploads = false;
|
||||||
|
elapse(const Duration(seconds: 60));
|
||||||
|
expect(service.uploadedBatchSizes, [1]);
|
||||||
|
|
||||||
|
// 退避已重置:新事件在下一个 30 秒刻度即上传,无残留等待。
|
||||||
|
service.trackEvent('page_viewed', {'pageName': 'feed'});
|
||||||
|
async.flushMicrotasks();
|
||||||
|
elapse(const Duration(seconds: 30));
|
||||||
|
expect(service.uploadedBatchSizes, [1, 1]);
|
||||||
|
service.stopPeriodicFlush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -137,5 +137,72 @@ void main() {
|
|||||||
expect(batchSizes, [40, 20]);
|
expect(batchSizes, [40, 20]);
|
||||||
expect(service.pendingEvents, isEmpty);
|
expect(service.pendingEvents, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('429 不按毒丸丢弃:保段待退避重试(限流分支随后端落地)', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final (server, batchSizes) = await startServer(429);
|
||||||
|
addTearDown(() => server.close(force: true));
|
||||||
|
final store = AnalyticsEventStore();
|
||||||
|
await store.restore();
|
||||||
|
final service = buildService('http://127.0.0.1:${server.port}', store);
|
||||||
|
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
await service.trackEvent('auth_login_succeeded', {'attemptSeq': i});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(batchSizes, [20]);
|
||||||
|
expect(service.pendingEvents.length, 20);
|
||||||
|
expect(store.droppedCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AnalyticsService anonymousId 持久化', () {
|
||||||
|
test('首次 restore 落盘生成的 anonymousId', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final service = buildService('http://unused', AnalyticsEventStore());
|
||||||
|
|
||||||
|
await service.restore();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(
|
||||||
|
prefs.getString(AnalyticsService.anonymousIdKey),
|
||||||
|
service.anonymousId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('冷启动新实例沿用持久化 anonymousId,事件跨启动可归并', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final serviceA = buildService('http://unused', AnalyticsEventStore());
|
||||||
|
await serviceA.restore();
|
||||||
|
final firstLaunchId = serviceA.anonymousId;
|
||||||
|
|
||||||
|
// 模拟冷启动:新实例构造时是新的随机 v4,restore 后采用存储值。
|
||||||
|
final serviceB = buildService('http://unused', AnalyticsEventStore());
|
||||||
|
expect(serviceB.anonymousId, isNot(firstLaunchId));
|
||||||
|
await serviceB.restore();
|
||||||
|
|
||||||
|
expect(serviceB.anonymousId, firstLaunchId);
|
||||||
|
await serviceB.trackEvent('auth_login_succeeded');
|
||||||
|
expect(serviceB.pendingEvents.single['anonymousId'], firstLaunchId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('构造注入 anonymousId 的测试通道不被持久化覆盖', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
AnalyticsService.anonymousIdKey: 'anon-stored',
|
||||||
|
});
|
||||||
|
final service = AnalyticsService(
|
||||||
|
apiBaseUrl: 'http://unused',
|
||||||
|
getAccessToken: null,
|
||||||
|
getSessionId: () => 'session-x',
|
||||||
|
anonymousId: 'anon-injected',
|
||||||
|
store: AnalyticsEventStore(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.restore();
|
||||||
|
|
||||||
|
expect(service.anonymousId, 'anon-injected');
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getString(AnalyticsService.anonymousIdKey), 'anon-stored');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,5 +122,18 @@ void main() {
|
|||||||
// 第 20 条触发上传,失败后批次应重回队列(M0 行为是整批清空)。
|
// 第 20 条触发上传,失败后批次应重回队列(M0 行为是整批清空)。
|
||||||
expect(service.pendingEvents.length, 20);
|
expect(service.pendingEvents.length, 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('持久化不可用时 restore 降级临时 anonymousId 不崩溃', () async {
|
||||||
|
// 本文件从不 setMockInitialValues:SharedPreferences 走真实
|
||||||
|
// 平台通道并抛异常,restore 须吞掉并保留构造时的临时 id。
|
||||||
|
final service = buildService();
|
||||||
|
final ephemeralId = service.anonymousId;
|
||||||
|
|
||||||
|
await service.restore();
|
||||||
|
await service.trackEvent('auth_login_succeeded');
|
||||||
|
|
||||||
|
expect(service.anonymousId, ephemeralId);
|
||||||
|
expect(service.pendingEvents.single['anonymousId'], ephemeralId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,5 +73,32 @@ void main() {
|
|||||||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||||
expect(tracker.sessionId, isNot(original));
|
expect(tracker.sessionId, isNot(original));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('前后台回调成对触发一次,生命周期级联不重复', () {
|
||||||
|
var leaveCount = 0;
|
||||||
|
var enterCount = 0;
|
||||||
|
final tracker = SessionTracker(
|
||||||
|
onLeaveForeground: () => leaveCount++,
|
||||||
|
onEnterForeground: () => enterCount++,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 退后台级联:inactive → hidden → paused 只回调 leave 一次。
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||||
|
expect(leaveCount, 1);
|
||||||
|
expect(enterCount, 0);
|
||||||
|
|
||||||
|
// 回前台级联:hidden → inactive → resumed 只回调 enter 一次。
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||||
|
expect(leaveCount, 1);
|
||||||
|
expect(enterCount, 1);
|
||||||
|
|
||||||
|
// 已在前台重复 resumed(冷启动首个 resumed 同形)不触发 enter。
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||||
|
expect(enterCount, 1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/signed_network_image.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('presignedImageCacheKey', () {
|
||||||
|
test('剥离全部 X-Amz-* 签名参数(大小写不敏感)', () {
|
||||||
|
const url =
|
||||||
|
'http://127.0.0.1:9000/patbond-media/post_image/a-1.jpg'
|
||||||
|
'?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=cred'
|
||||||
|
'&X-Amz-Date=20260909T000000Z&X-Amz-Expires=3600'
|
||||||
|
'&X-Amz-SignedHeaders=host&x-amz-signature=deadbeef';
|
||||||
|
expect(
|
||||||
|
presignedImageCacheKey(url),
|
||||||
|
'http://127.0.0.1:9000/patbond-media/post_image/a-1.jpg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('保留非签名 query 参数', () {
|
||||||
|
const url = 'https://cdn.example.com/p.jpg?w=300&X-Amz-Signature=sig';
|
||||||
|
expect(
|
||||||
|
presignedImageCacheKey(url),
|
||||||
|
'https://cdn.example.com/p.jpg?w=300',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无签名参数原样返回', () {
|
||||||
|
expect(
|
||||||
|
presignedImageCacheKey('https://cdn.example.com/p.jpg?w=300&q=80'),
|
||||||
|
'https://cdn.example.com/p.jpg?w=300&q=80',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
presignedImageCacheKey('https://cdn.example.com/p.jpg'),
|
||||||
|
'https://cdn.example.com/p.jpg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法 URL 不炸,退回原串', () {
|
||||||
|
expect(presignedImageCacheKey('::not a url::'), '::not a url::');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SignedNetworkImage', () {
|
||||||
|
test('同对象不同签名 → 判等(命中同一 ImageCache 条目)', () {
|
||||||
|
final first = SignedNetworkImage(
|
||||||
|
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig1&X-Amz-Date=d1',
|
||||||
|
);
|
||||||
|
final second = SignedNetworkImage(
|
||||||
|
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig2&X-Amz-Date=d2',
|
||||||
|
);
|
||||||
|
expect(first, second);
|
||||||
|
expect(first.hashCode, second.hashCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('不同对象 → 不判等', () {
|
||||||
|
final first = SignedNetworkImage(
|
||||||
|
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig',
|
||||||
|
);
|
||||||
|
final second = SignedNetworkImage(
|
||||||
|
'http://127.0.0.1:9000/m/b.jpg?X-Amz-Signature=sig',
|
||||||
|
);
|
||||||
|
expect(first, isNot(second));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scale 参与判等', () {
|
||||||
|
final first = SignedNetworkImage('http://h/m/a.jpg');
|
||||||
|
final second = SignedNetworkImage('http://h/m/a.jpg', scale: 2);
|
||||||
|
expect(first, isNot(second));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/comment_tile.dart';
|
||||||
|
|
||||||
|
import '../../helpers/community_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final now = DateTime.parse('2026-09-08T13:00:00.000Z');
|
||||||
|
|
||||||
|
Widget wrap(Widget child) => MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Scaffold(body: Center(child: child)),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('渲染作者名 / 内容 / 相对时间;无 onDelete 不显删除', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(CommentTile(comment: sampleComment(), now: now)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('毛毛的铲屎官'), findsOneWidget);
|
||||||
|
expect(find.text('好可爱!', findRichText: true), findsOneWidget);
|
||||||
|
expect(find.text('2 小时前'), findsOneWidget);
|
||||||
|
expect(find.text('删除'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('@ 回复以「回复 @昵称:」前缀呈现;降级作者「宠友」占位', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
CommentTile(
|
||||||
|
comment: sampleComment(
|
||||||
|
author: sampleAuthorJson(nickname: null, avatarUrl: null),
|
||||||
|
replyToUser: sampleAuthorJson(userId: 'u-2', nickname: '豆豆麻麻'),
|
||||||
|
),
|
||||||
|
now: now,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('宠友'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.textContaining('回复 @豆豆麻麻:', findRichText: true),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('onDelete 提供时显删除入口并回调;deleting 期间转圈锁定', (tester) async {
|
||||||
|
var deleted = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
CommentTile(
|
||||||
|
comment: sampleComment(),
|
||||||
|
now: now,
|
||||||
|
onDelete: () => deleted++,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('删除'));
|
||||||
|
expect(deleted, 1);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
CommentTile(
|
||||||
|
comment: sampleComment(),
|
||||||
|
now: now,
|
||||||
|
deleting: true,
|
||||||
|
onDelete: () => deleted++,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('删除'), findsNothing);
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/like_button.dart';
|
||||||
|
|
||||||
|
/// 宿主:模拟持有方(controller)的状态翻转——点按经 [onTap] 乐观翻转,
|
||||||
|
/// 外部(回滚/对账)经 [setActive] 直接改。
|
||||||
|
class _Host extends StatefulWidget {
|
||||||
|
const _Host({required this.initialActive, required this.initialCount});
|
||||||
|
|
||||||
|
final bool initialActive;
|
||||||
|
final int initialCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Host> createState() => _HostState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HostState extends State<_Host> {
|
||||||
|
late bool active = widget.initialActive;
|
||||||
|
late int count = widget.initialCount;
|
||||||
|
|
||||||
|
void setExternal({required bool active, required int count}) {
|
||||||
|
setState(() {
|
||||||
|
this.active = active;
|
||||||
|
this.count = count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: LikeButton(
|
||||||
|
variant: LikeButtonVariant.like,
|
||||||
|
active: active,
|
||||||
|
count: count,
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
active = !active;
|
||||||
|
count += active ? 1 : -1;
|
||||||
|
}),
|
||||||
|
semanticLabel: '点赞',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
double scaleOf(WidgetTester tester) => tester
|
||||||
|
.widget<ScaleTransition>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(LikeButton),
|
||||||
|
matching: find.byType(ScaleTransition),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.scale
|
||||||
|
.value;
|
||||||
|
|
||||||
|
testWidgets('点按激活:240ms 弹性缩放动画(中途 >1,播完归位)', (tester) async {
|
||||||
|
await tester.pumpWidget(const _Host(initialActive: false, initialCount: 6));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.favorite_border));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 90));
|
||||||
|
expect(scaleOf(tester), greaterThan(1.0));
|
||||||
|
expect(find.byIcon(Icons.favorite), findsOneWidget);
|
||||||
|
expect(find.text('7'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(scaleOf(tester), 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('点按取消:仅颜色切换,无缩放动画', (tester) async {
|
||||||
|
await tester.pumpWidget(const _Host(initialActive: true, initialCount: 7));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.favorite));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 90));
|
||||||
|
expect(scaleOf(tester), 1.0);
|
||||||
|
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
|
||||||
|
expect(find.text('6'), findsOneWidget);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('外部回滚:零动画直接跳变,计数与状态成对恢复', (tester) async {
|
||||||
|
await tester.pumpWidget(const _Host(initialActive: false, initialCount: 6));
|
||||||
|
|
||||||
|
// 点按激活并播完动画(在途请求随后失败的场景)。
|
||||||
|
await tester.tap(find.byIcon(Icons.favorite_border));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byIcon(Icons.favorite), findsOneWidget);
|
||||||
|
|
||||||
|
// 外部(非点按)回滚:下一帧即恢复,无过渡动画。
|
||||||
|
tester
|
||||||
|
.state<_HostState>(find.byType(_Host))
|
||||||
|
.setExternal(active: false, count: 6);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
|
||||||
|
expect(find.text('6'), findsOneWidget);
|
||||||
|
expect(scaleOf(tester), 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('激活动画未播完时回滚:等播完再成对跳变(§4.3a 抖动抑制)', (tester) async {
|
||||||
|
await tester.pumpWidget(const _Host(initialActive: false, initialCount: 6));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.favorite_border));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
expect(find.byIcon(Icons.favorite), findsOneWidget);
|
||||||
|
|
||||||
|
// 动画中途回滚:本帧仍显示激活视觉(等待播完),计数同持。
|
||||||
|
tester
|
||||||
|
.state<_HostState>(find.byType(_Host))
|
||||||
|
.setExternal(active: false, count: 6);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byIcon(Icons.favorite), findsOneWidget);
|
||||||
|
expect(find.text('7'), findsOneWidget);
|
||||||
|
|
||||||
|
// 播完后成对跳回。
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
|
||||||
|
expect(find.text('6'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('对账静默:状态不变的计数变化直接替换,无动画', (tester) async {
|
||||||
|
await tester.pumpWidget(const _Host(initialActive: true, initialCount: 7));
|
||||||
|
|
||||||
|
tester
|
||||||
|
.state<_HostState>(find.byType(_Host))
|
||||||
|
.setExternal(active: true, count: 9);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('9'), findsOneWidget);
|
||||||
|
expect(scaleOf(tester), 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('onPressed 为 null 时禁用但照常渲染激活态', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: LikeButton(
|
||||||
|
variant: LikeButtonVariant.bookmark,
|
||||||
|
active: true,
|
||||||
|
count: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byIcon(Icons.bookmark), findsOneWidget);
|
||||||
|
expect(find.text('2'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/like_button.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_card.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
import '../../helpers/community_test_helpers.dart';
|
||||||
|
|
||||||
|
FeedCard cardFrom(Map<String, dynamic> overrides) =>
|
||||||
|
FeedCard.fromJson({...sampleFeedCardJson(), ...overrides});
|
||||||
|
|
||||||
|
Widget wrap(Widget child) => MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('单图形态:通栏 4:3 出血,不走九宫格,预览 2 行', (tester) async {
|
||||||
|
final card = cardFrom({'mediaCount': 1});
|
||||||
|
await tester.pumpWidget(wrap(PostCard(card: card)));
|
||||||
|
|
||||||
|
expect(find.byType(AspectRatio), findsOneWidget);
|
||||||
|
expect(find.byType(PostMediaGrid), findsNothing);
|
||||||
|
final preview = tester.widget<Text>(find.text('晒了一下午太阳。'));
|
||||||
|
expect(preview.maxLines, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('多图形态:九宫格折叠封面 + 「+N」角标(契约只带封面与计数)', (tester) async {
|
||||||
|
final card = cardFrom({'mediaCount': 5});
|
||||||
|
await tester.pumpWidget(wrap(PostCard(card: card)));
|
||||||
|
|
||||||
|
expect(find.byType(PostMediaGrid), findsOneWidget);
|
||||||
|
expect(find.text('+4'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('纯文字形态:无媒体区,正文放宽 6 行、15/1.6', (tester) async {
|
||||||
|
final card = cardFrom({'coverImage': null, 'mediaCount': 0});
|
||||||
|
await tester.pumpWidget(wrap(PostCard(card: card)));
|
||||||
|
|
||||||
|
expect(find.byType(PostMediaGrid), findsNothing);
|
||||||
|
expect(find.byType(AspectRatio), findsNothing);
|
||||||
|
final preview = tester.widget<Text>(find.text('晒了一下午太阳。'));
|
||||||
|
expect(preview.maxLines, 6);
|
||||||
|
expect(preview.style?.fontSize, 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('头部行:作者名 + 相对时间;求助帖追加 accent 标', (tester) async {
|
||||||
|
final card = cardFrom({
|
||||||
|
'category': 'help',
|
||||||
|
'publishedAt': DateTime.now()
|
||||||
|
.subtract(const Duration(hours: 2))
|
||||||
|
.toUtc()
|
||||||
|
.toIso8601String(),
|
||||||
|
});
|
||||||
|
await tester.pumpWidget(wrap(PostCard(card: card)));
|
||||||
|
|
||||||
|
expect(find.text('毛毛的铲屎官'), findsOneWidget);
|
||||||
|
expect(find.text('2 小时前'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(TagPill, '求助'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('降级作者:占位头像 + 「宠友」默认名', (tester) async {
|
||||||
|
final card = cardFrom({
|
||||||
|
'author': sampleAuthorJson(nickname: null, avatarUrl: null),
|
||||||
|
});
|
||||||
|
await tester.pumpWidget(wrap(PostCard(card: card)));
|
||||||
|
|
||||||
|
expect(find.text('宠友'), findsOneWidget);
|
||||||
|
expect(find.text('毛毛的铲屎官'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('操作行:计数与激活态展示;T3-14 纯展示点按无副作用', (tester) async {
|
||||||
|
final card = cardFrom({
|
||||||
|
'likedByMe': true,
|
||||||
|
'likeCount': 6,
|
||||||
|
'bookmarkedByMe': false,
|
||||||
|
'bookmarkCount': 2,
|
||||||
|
'commentCount': 3,
|
||||||
|
});
|
||||||
|
await tester.pumpWidget(wrap(PostCard(card: card)));
|
||||||
|
|
||||||
|
expect(find.text('6'), findsOneWidget);
|
||||||
|
expect(find.text('2'), findsOneWidget);
|
||||||
|
expect(find.text('3'), findsOneWidget);
|
||||||
|
// 点赞激活:实心 favorite + error 色(Colors.red 修订 D6)。
|
||||||
|
final likeIcon = tester.widget<Icon>(find.byIcon(Icons.favorite));
|
||||||
|
expect(likeIcon.color, AppColors.error);
|
||||||
|
// 收藏未激活:描边 + inkSoft。
|
||||||
|
final bookmarkIcon = tester.widget<Icon>(
|
||||||
|
find.byIcon(Icons.bookmark_border),
|
||||||
|
);
|
||||||
|
expect(bookmarkIcon.color, AppColors.inkSoft);
|
||||||
|
|
||||||
|
// 回调未接(null):点按不抛错、无状态变化。
|
||||||
|
await tester.tap(find.byType(LikeButton).first, warnIfMissed: false);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byIcon(Icons.favorite), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/feed_skeleton.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/upload_progress_overlay.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
import '../../helpers/media_test_helpers.dart';
|
||||||
|
|
||||||
|
Widget wrap(Widget child, {bool disableAnimations = false}) => MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: MediaQuery(
|
||||||
|
data: MediaQueryData(disableAnimations: disableAnimations),
|
||||||
|
child: Scaffold(body: SingleChildScrollView(child: child)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> urls(int count) => List.generate(
|
||||||
|
count,
|
||||||
|
(i) => 'https://minio.local/p$i.jpg?X-Amz-Signature=s',
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('PostMediaGrid 列数规则(05 §3.2)', () {
|
||||||
|
test('2、4 图 → 2 列;3、5–9 图 → 3 列', () {
|
||||||
|
expect(PostMediaGrid.columnsFor(2), 2);
|
||||||
|
expect(PostMediaGrid.columnsFor(4), 2);
|
||||||
|
expect(PostMediaGrid.columnsFor(3), 3);
|
||||||
|
for (var n = 5; n <= 9; n++) {
|
||||||
|
expect(PostMediaGrid.columnsFor(n), 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('4 图渲染 4 格网格', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(PostMediaGrid(urls: urls(4))));
|
||||||
|
expect(find.byType(RemoteImage), findsNWidgets(4));
|
||||||
|
expect(find.byType(GridView), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('超 9 图折叠:显 9 格,末格 ink 80% scrim + 「+N」', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(PostMediaGrid(urls: urls(12), totalCount: 12)),
|
||||||
|
);
|
||||||
|
expect(find.byType(RemoteImage), findsNWidgets(9));
|
||||||
|
expect(find.text('+3'), findsOneWidget);
|
||||||
|
final scrim = tester
|
||||||
|
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
|
||||||
|
.map((w) => w.decoration)
|
||||||
|
.whereType<BoxDecoration>()
|
||||||
|
.where((d) => d.color == AppColors.ink.withAlpha(204));
|
||||||
|
expect(scrim, isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('单图折叠形态(Feed 封面 + 总数):4:3 单格 + 角标胶囊', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(PostMediaGrid(urls: urls(1), totalCount: 3)),
|
||||||
|
);
|
||||||
|
expect(find.byType(GridView), findsNothing);
|
||||||
|
expect(find.byType(AspectRatio), findsOneWidget);
|
||||||
|
expect(find.text('+2'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('单图无折余不出角标', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(PostMediaGrid(urls: urls(1))));
|
||||||
|
expect(find.textContaining('+'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('FeedSkeleton(05 §3.7)', () {
|
||||||
|
testWidgets('骨架单元:头部圆 + 4:3 块 + 正文横条,呼吸动效运行', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(const FeedSkeleton()));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
expect(find.byType(AspectRatio), findsOneWidget);
|
||||||
|
final fade = tester.widget<FadeTransition>(
|
||||||
|
find
|
||||||
|
.descendant(
|
||||||
|
of: find.byType(FeedSkeleton),
|
||||||
|
matching: find.byType(FadeTransition),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
);
|
||||||
|
final controller = fade.opacity as AnimationController;
|
||||||
|
expect(controller.isAnimating, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('系统减弱动态效果:动画静止在 1.0(pumpAndSettle 可收敛)', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(const FeedSkeleton(), disableAnimations: true),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final fade = tester.widget<FadeTransition>(
|
||||||
|
find
|
||||||
|
.descendant(
|
||||||
|
of: find.byType(FeedSkeleton),
|
||||||
|
matching: find.byType(FadeTransition),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
);
|
||||||
|
expect(fade.opacity.value, 1.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PostMediaEditGrid 编辑态(05 §3.2)', () {
|
||||||
|
MediaUploadItem item({
|
||||||
|
int localId = 1,
|
||||||
|
MediaItemPhase phase = MediaItemPhase.ready,
|
||||||
|
double progress = 0,
|
||||||
|
bool retryable = false,
|
||||||
|
}) => MediaUploadItem(
|
||||||
|
localId: localId,
|
||||||
|
phase: phase,
|
||||||
|
previewBytes: tinyPngBytes,
|
||||||
|
progress: progress,
|
||||||
|
assetId: phase == MediaItemPhase.ready ? 'a-$localId' : null,
|
||||||
|
retryable: retryable,
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('空列表只渲染「+」格;3 张图渲染 3 格 + 「+」', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(const PostMediaEditGrid(items: [])));
|
||||||
|
expect(find.byIcon(Icons.add_photo_alternate_outlined), findsOneWidget);
|
||||||
|
expect(find.byType(Image), findsNothing);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
PostMediaEditGrid(
|
||||||
|
items: [item(localId: 1), item(localId: 2), item(localId: 3)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(Image), findsNWidgets(3));
|
||||||
|
expect(find.byIcon(Icons.add_photo_alternate_outlined), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('满 9 张(canAdd=false)隐藏「+」格', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
PostMediaEditGrid(
|
||||||
|
items: List.generate(9, (i) => item(localId: i + 1)),
|
||||||
|
canAdd: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byIcon(Icons.add_photo_alternate_outlined), findsNothing);
|
||||||
|
expect(find.byType(Image), findsNWidgets(9));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('删除角标回传 localId;「+」格回调', (tester) async {
|
||||||
|
final removed = <int>[];
|
||||||
|
var addTaps = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
PostMediaEditGrid(
|
||||||
|
items: [item(localId: 7), item(localId: 8)],
|
||||||
|
onAdd: () => addTaps++,
|
||||||
|
onRemove: removed.add,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byIcon(Icons.close).first);
|
||||||
|
expect(removed, [7]);
|
||||||
|
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
|
||||||
|
expect(addTaps, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('上传中格叠进度覆盖层;可重试失败格整格点按重试,终态不响应', (tester) async {
|
||||||
|
final retried = <int>[];
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
PostMediaEditGrid(
|
||||||
|
items: [
|
||||||
|
item(localId: 1, phase: MediaItemPhase.uploading, progress: 0.42),
|
||||||
|
item(localId: 2, phase: MediaItemPhase.failed, retryable: true),
|
||||||
|
item(localId: 3, phase: MediaItemPhase.failed),
|
||||||
|
],
|
||||||
|
onRetry: retried.add,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(UploadProgressOverlay), findsNWidgets(3));
|
||||||
|
expect(find.text('42%'), findsOneWidget);
|
||||||
|
// 可重试格有「重试」通栏,终态格没有。
|
||||||
|
expect(find.text('重试'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('重试'));
|
||||||
|
expect(retried, [2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import 'package:patbond_flutter/core/theme/app_theme.dart';
|
|||||||
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('五类映射齐备:图标 + 双色(05 §2 表)', (tester) async {
|
testWidgets('全类型映射齐备:图标 + 双色(05 §2 表 + T2-14 增补三型)', (tester) async {
|
||||||
// 映射表唯一出口:五类各有图标、底色、图标色、文字色、文案。
|
// 映射表唯一出口:各类型均有图标、底色、图标色、文字色、文案。
|
||||||
expect(recordTypeStyles.length, RecordType.values.length);
|
expect(recordTypeStyles.length, RecordType.values.length);
|
||||||
expect(
|
expect(
|
||||||
recordTypeStyles[RecordType.weight]!.iconColor,
|
recordTypeStyles[RecordType.weight]!.iconColor,
|
||||||
@@ -26,6 +26,25 @@ void main() {
|
|||||||
expect(recordTypeStyles[RecordType.medical]!.inkColor, AppColors.errorDark);
|
expect(recordTypeStyles[RecordType.medical]!.inkColor, AppColors.errorDark);
|
||||||
expect(recordTypeStyles[RecordType.other]!.inkColor, AppColors.inkSoft);
|
expect(recordTypeStyles[RecordType.other]!.inkColor, AppColors.inkSoft);
|
||||||
expect(recordTypeStyles[RecordType.medical]!.label, '就医');
|
expect(recordTypeStyles[RecordType.medical]!.label, '就医');
|
||||||
|
// T2-14 增补三型:色族复用已审计色对,仅图标/文案区分。
|
||||||
|
expect(
|
||||||
|
recordTypeStyles[RecordType.feeding]!.inkColor,
|
||||||
|
AppColors.successInk,
|
||||||
|
);
|
||||||
|
expect(recordTypeStyles[RecordType.feeding]!.label, '喂养');
|
||||||
|
expect(
|
||||||
|
recordTypeStyles[RecordType.grooming]!.inkColor,
|
||||||
|
AppColors.accentDark,
|
||||||
|
);
|
||||||
|
expect(recordTypeStyles[RecordType.grooming]!.label, '洗护');
|
||||||
|
expect(
|
||||||
|
recordTypeStyles[RecordType.measurement]!.inkColor,
|
||||||
|
AppColors.primaryDark,
|
||||||
|
);
|
||||||
|
expect(recordTypeStyles[RecordType.measurement]!.label, '测量');
|
||||||
|
// 三型图标彼此不同(与既有五型也不重复),保证图标通道可辨。
|
||||||
|
final icons = {for (final style in recordTypeStyles.values) style.icon};
|
||||||
|
expect(icons.length, RecordType.values.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('圆标渲染:尺寸档正确、图标为 dot 的 50%、底为基色 8% 淡染', (tester) async {
|
testWidgets('圆标渲染:尺寸档正确、图标为 dot 的 50%、底为基色 8% 淡染', (tester) async {
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
|
||||||
|
import '../../helpers/community_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeCommunityRepository repo;
|
||||||
|
late CommunityController controller;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repo = FakeCommunityRepository();
|
||||||
|
controller = CommunityController(repository: repo);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => controller.dispose());
|
||||||
|
|
||||||
|
List<String> feedIds() => controller.feed.map((card) => card.id).toList();
|
||||||
|
|
||||||
|
group('Feed 四态与多页缓存', () {
|
||||||
|
test('首屏成功:initial → loading → ready,页数据落位', () async {
|
||||||
|
repo.onFeed = (limit, cursor) async =>
|
||||||
|
feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
|
||||||
|
|
||||||
|
expect(controller.phase, FeedPhase.initial);
|
||||||
|
final pending = controller.refresh();
|
||||||
|
expect(controller.phase, FeedPhase.loading);
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(controller.phase, FeedPhase.ready);
|
||||||
|
expect(feedIds(), ['p-1']);
|
||||||
|
expect(controller.hasMore, isTrue);
|
||||||
|
expect(controller.isEmpty, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('首屏失败:error 态供 retry;重试成功恢复 ready', () async {
|
||||||
|
var fail = true;
|
||||||
|
repo.onFeed = (limit, cursor) async {
|
||||||
|
if (fail) throw const ApiNetworkException('断网');
|
||||||
|
return feedPage([sampleFeedCard()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await controller.refresh();
|
||||||
|
expect(controller.phase, FeedPhase.error);
|
||||||
|
expect(controller.lastError, isA<ApiNetworkException>());
|
||||||
|
expect(controller.feed, isEmpty);
|
||||||
|
|
||||||
|
fail = false;
|
||||||
|
await controller.refresh();
|
||||||
|
expect(controller.phase, FeedPhase.ready);
|
||||||
|
expect(controller.lastError, isNull);
|
||||||
|
expect(feedIds(), ['p-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('刷新失败保留旧列表:不清空不闪空态,错误走 refreshError', () async {
|
||||||
|
var fail = false;
|
||||||
|
repo.onFeed = (limit, cursor) async {
|
||||||
|
if (fail) throw const ApiNetworkException('超时');
|
||||||
|
return feedPage([sampleFeedCard()]);
|
||||||
|
};
|
||||||
|
await controller.refresh();
|
||||||
|
|
||||||
|
fail = true;
|
||||||
|
await controller.refresh();
|
||||||
|
|
||||||
|
expect(controller.phase, FeedPhase.ready);
|
||||||
|
expect(feedIds(), ['p-1']);
|
||||||
|
expect(controller.refreshError, isA<ApiNetworkException>());
|
||||||
|
expect(controller.lastError, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ready 且列表为空 → 空态', () async {
|
||||||
|
repo.onFeed = (limit, cursor) async => feedPage(const []);
|
||||||
|
await controller.refresh();
|
||||||
|
expect(controller.isEmpty, isTrue);
|
||||||
|
expect(controller.hasMore, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('加载更多与游标拼接', () {
|
||||||
|
test('loadMore 携带上一页 nextCursor,追加不替换;到底后不再发', () async {
|
||||||
|
repo.onFeed = (limit, cursor) async => switch (cursor) {
|
||||||
|
null => feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true),
|
||||||
|
'c1' => feedPage([sampleFeedCard(id: 'p-2')]),
|
||||||
|
_ => fail('意外游标:$cursor'),
|
||||||
|
};
|
||||||
|
|
||||||
|
await controller.refresh();
|
||||||
|
await controller.loadMore();
|
||||||
|
|
||||||
|
expect(repo.calls, ['feed:cursor=null', 'feed:cursor=c1']);
|
||||||
|
expect(feedIds(), ['p-1', 'p-2']);
|
||||||
|
expect(controller.hasMore, isFalse);
|
||||||
|
expect(controller.loadMorePhase, LoadMorePhase.idle);
|
||||||
|
|
||||||
|
// hasMore=false:不再发请求。
|
||||||
|
await controller.loadMore();
|
||||||
|
expect(repo.calls, hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadMore 失败:error 态保留列表,重试成功恢复', () async {
|
||||||
|
var fail = true;
|
||||||
|
repo.onFeed = (limit, cursor) async {
|
||||||
|
if (cursor == null) {
|
||||||
|
return feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
|
||||||
|
}
|
||||||
|
if (fail) throw const ApiNetworkException('超时');
|
||||||
|
return feedPage([sampleFeedCard(id: 'p-2')]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await controller.refresh();
|
||||||
|
await controller.loadMore();
|
||||||
|
expect(controller.loadMorePhase, LoadMorePhase.error);
|
||||||
|
expect(controller.loadMoreError, isA<ApiNetworkException>());
|
||||||
|
expect(feedIds(), ['p-1']);
|
||||||
|
|
||||||
|
fail = false;
|
||||||
|
await controller.loadMore();
|
||||||
|
expect(controller.loadMorePhase, LoadMorePhase.idle);
|
||||||
|
expect(feedIds(), ['p-1', 'p-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('在途 loadMore 与刷新竞态:旧代次尾页丢弃,不重复不错位', () async {
|
||||||
|
final tail = Completer<CursorPage<FeedCard>>();
|
||||||
|
repo.onFeed = (limit, cursor) async {
|
||||||
|
if (cursor == null) {
|
||||||
|
return feedPage(
|
||||||
|
[sampleFeedCard(id: 'p-fresh')],
|
||||||
|
nextCursor: 'c1',
|
||||||
|
hasMore: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tail.future;
|
||||||
|
};
|
||||||
|
|
||||||
|
await controller.refresh();
|
||||||
|
final pending = controller.loadMore(); // 在途旧代次尾页。
|
||||||
|
await controller.refresh(); // 期间刷新:整体替换 + 代次 +1。
|
||||||
|
tail.complete(feedPage([sampleFeedCard(id: 'p-stale')]));
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(feedIds(), ['p-fresh']);
|
||||||
|
expect(controller.hasMore, isTrue); // 保持新代次首页的分页状态。
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loading 中重复 loadMore 只发一请求(单飞)', () async {
|
||||||
|
final tail = Completer<CursorPage<FeedCard>>();
|
||||||
|
repo.onFeed = (limit, cursor) async => cursor == null
|
||||||
|
? feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true)
|
||||||
|
: tail.future;
|
||||||
|
|
||||||
|
await controller.refresh();
|
||||||
|
final first = controller.loadMore();
|
||||||
|
final second = controller.loadMore(); // loading 中:直接返回。
|
||||||
|
tail.complete(feedPage([sampleFeedCard(id: 'p-2')]));
|
||||||
|
await first;
|
||||||
|
await second;
|
||||||
|
|
||||||
|
expect(repo.calls.where((c) => c == 'feed:cursor=c1'), hasLength(1));
|
||||||
|
expect(feedIds(), ['p-1', 'p-2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ToggleSync 乐观更新(点赞 / 收藏同构)', () {
|
||||||
|
Future<void> loadOneCard({bool liked = false, int likeCount = 6}) async {
|
||||||
|
repo.onFeed = (limit, cursor) async =>
|
||||||
|
feedPage([sampleFeedCard(likedByMe: liked, likeCount: likeCount)]);
|
||||||
|
await controller.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('成功:同帧乐观翻转,权威计数覆盖乐观计数', () async {
|
||||||
|
await loadOneCard();
|
||||||
|
repo.onLikeToggle = (id, target) async =>
|
||||||
|
const LikeState(liked: true, likeCount: 10); // 吸收他人并发 +3。
|
||||||
|
|
||||||
|
controller.toggleLike('p-1');
|
||||||
|
// 同帧反馈:乐观 +1。
|
||||||
|
expect(controller.feed.single.likedByMe, isTrue);
|
||||||
|
expect(controller.feed.single.likeCount, 7);
|
||||||
|
|
||||||
|
await pumpEventQueue();
|
||||||
|
// 权威终态覆盖。
|
||||||
|
expect(controller.feed.single.likedByMe, isTrue);
|
||||||
|
expect(controller.feed.single.likeCount, 10);
|
||||||
|
expect(controller.toggleError, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('失败:恢复链起点快照,错误走 toggleError 轻提示', () async {
|
||||||
|
await loadOneCard();
|
||||||
|
repo.onLikeToggle = (id, target) async =>
|
||||||
|
throw const ApiNetworkException('断网');
|
||||||
|
|
||||||
|
controller.toggleLike('p-1');
|
||||||
|
expect(controller.feed.single.likedByMe, isTrue);
|
||||||
|
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(controller.feed.single.likedByMe, isFalse);
|
||||||
|
expect(controller.feed.single.likeCount, 6);
|
||||||
|
expect(controller.toggleError, isA<ApiNetworkException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('在途连点单飞:只发一请求,完成后按最终意图补发一次', () async {
|
||||||
|
await loadOneCard();
|
||||||
|
final completers = <Completer<LikeState>>[];
|
||||||
|
repo.onLikeToggle = (id, target) {
|
||||||
|
final completer = Completer<LikeState>();
|
||||||
|
completers.add(completer);
|
||||||
|
return completer.future;
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.toggleLike('p-1'); // → true,在途。
|
||||||
|
controller.toggleLike('p-1'); // → false,只记意图不发请求。
|
||||||
|
expect(repo.calls.where((c) => c.startsWith('like')), hasLength(1));
|
||||||
|
expect(controller.feed.single.likedByMe, isFalse);
|
||||||
|
expect(controller.feed.single.likeCount, 6);
|
||||||
|
|
||||||
|
completers[0].complete(const LikeState(liked: true, likeCount: 7));
|
||||||
|
await pumpEventQueue();
|
||||||
|
// 确认态 true ≠ 最终意图 false → 补发 DELETE。
|
||||||
|
expect(repo.calls, contains('unlike:p-1'));
|
||||||
|
completers[1].complete(const LikeState(liked: false, likeCount: 6));
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(controller.feed.single.likedByMe, isFalse);
|
||||||
|
expect(controller.feed.single.likeCount, 6);
|
||||||
|
// 全程恰两个请求(中间抖动被合并)。
|
||||||
|
expect(
|
||||||
|
repo.calls.where((c) => c.startsWith('like') || c.startsWith('unlike')),
|
||||||
|
hasLength(2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('补发目标与终态一致不再发:连点偶数次收敛回原意图', () async {
|
||||||
|
await loadOneCard();
|
||||||
|
final completers = <Completer<LikeState>>[];
|
||||||
|
repo.onLikeToggle = (id, target) {
|
||||||
|
final completer = Completer<LikeState>();
|
||||||
|
completers.add(completer);
|
||||||
|
return completer.future;
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.toggleLike('p-1'); // → true,在途。
|
||||||
|
controller.toggleLike('p-1'); // → false。
|
||||||
|
controller.toggleLike('p-1'); // → true(最终意图与在途目标一致)。
|
||||||
|
|
||||||
|
completers.single.complete(const LikeState(liked: true, likeCount: 20));
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
// 意图 == 确认态:不补发,权威计数覆盖。
|
||||||
|
expect(
|
||||||
|
repo.calls.where((c) => c.startsWith('like') || c.startsWith('unlike')),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
expect(controller.feed.single.likedByMe, isTrue);
|
||||||
|
expect(controller.feed.single.likeCount, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('代次守卫:刷新后到达的旧成功响应丢弃,不覆盖新数据', () async {
|
||||||
|
await loadOneCard();
|
||||||
|
final inFlight = Completer<LikeState>();
|
||||||
|
repo.onLikeToggle = (id, target) => inFlight.future;
|
||||||
|
|
||||||
|
controller.toggleLike('p-1');
|
||||||
|
// 期间刷新:列表被服务端数据整体替换(liked=false count=0)。
|
||||||
|
repo.onFeed = (limit, cursor) async =>
|
||||||
|
feedPage([sampleFeedCard(likeCount: 0)]);
|
||||||
|
await controller.refresh();
|
||||||
|
|
||||||
|
inFlight.complete(const LikeState(liked: true, likeCount: 99));
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(controller.feed.single.likedByMe, isFalse);
|
||||||
|
expect(controller.feed.single.likeCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('代次守卫:刷新后到达的旧失败响应不回滚不提示', () async {
|
||||||
|
await loadOneCard();
|
||||||
|
final inFlight = Completer<LikeState>();
|
||||||
|
repo.onLikeToggle = (id, target) => inFlight.future;
|
||||||
|
|
||||||
|
controller.toggleLike('p-1');
|
||||||
|
repo.onFeed = (limit, cursor) async =>
|
||||||
|
feedPage([sampleFeedCard(likeCount: 0)]);
|
||||||
|
await controller.refresh();
|
||||||
|
|
||||||
|
inFlight.completeError(const ApiNetworkException('超时'));
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(controller.feed.single.likedByMe, isFalse);
|
||||||
|
expect(controller.feed.single.likeCount, 0);
|
||||||
|
expect(controller.toggleError, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收藏同构:乐观翻转 + 权威终态覆盖', () async {
|
||||||
|
await loadOneCard();
|
||||||
|
repo.onBookmarkToggle = (id, target) async =>
|
||||||
|
const BookmarkState(bookmarked: true, bookmarkCount: 5);
|
||||||
|
|
||||||
|
controller.toggleBookmark('p-1');
|
||||||
|
expect(controller.feed.single.bookmarkedByMe, isTrue);
|
||||||
|
expect(controller.feed.single.bookmarkCount, 3);
|
||||||
|
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(controller.feed.single.bookmarkCount, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('对不在列表的 postId 点击作废:不发请求不崩溃', () async {
|
||||||
|
await loadOneCard();
|
||||||
|
controller.toggleLike('p-nonexistent');
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(repo.calls.where((c) => c.startsWith('like')), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('详情副本与登出清态', () {
|
||||||
|
test('getPost:详情入缓存并回写 Feed 卡片互动字段', () async {
|
||||||
|
repo.onFeed = (limit, cursor) async => feedPage([sampleFeedCard()]);
|
||||||
|
await controller.refresh();
|
||||||
|
repo.onGetPost = (id) async =>
|
||||||
|
Post.fromJson(samplePostJson(likedByMe: true, likeCount: 42));
|
||||||
|
|
||||||
|
final post = await controller.getPost('p-1');
|
||||||
|
|
||||||
|
expect(post.likeCount, 42);
|
||||||
|
expect(controller.cachedPost('p-1')!.likedByMe, isTrue);
|
||||||
|
expect(controller.feed.single.likedByMe, isTrue);
|
||||||
|
expect(controller.feed.single.likeCount, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('详情副本存在时 toggle 同步详情与卡片两份内存', () async {
|
||||||
|
repo.onFeed = (limit, cursor) async => feedPage([sampleFeedCard()]);
|
||||||
|
await controller.refresh();
|
||||||
|
repo.onGetPost = (id) async => Post.fromJson(samplePostJson());
|
||||||
|
await controller.getPost('p-1');
|
||||||
|
repo.onLikeToggle = (id, target) async =>
|
||||||
|
const LikeState(liked: true, likeCount: 7);
|
||||||
|
|
||||||
|
controller.toggleLike('p-1');
|
||||||
|
expect(controller.cachedPost('p-1')!.likedByMe, isTrue);
|
||||||
|
expect(controller.feed.single.likedByMe, isTrue);
|
||||||
|
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(controller.cachedPost('p-1')!.likeCount, 7);
|
||||||
|
expect(controller.feed.single.likeCount, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset:清列表 / 游标 / 详情副本回 initial,在途响应作废', () async {
|
||||||
|
repo.onFeed = (limit, cursor) async =>
|
||||||
|
feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
|
||||||
|
await controller.refresh();
|
||||||
|
final inFlight = Completer<LikeState>();
|
||||||
|
repo.onLikeToggle = (id, target) => inFlight.future;
|
||||||
|
controller.toggleLike('p-1');
|
||||||
|
|
||||||
|
controller.reset();
|
||||||
|
|
||||||
|
expect(controller.phase, FeedPhase.initial);
|
||||||
|
expect(controller.feed, isEmpty);
|
||||||
|
expect(controller.hasMore, isFalse);
|
||||||
|
expect(controller.cachedPost('p-1'), isNull);
|
||||||
|
expect(controller.toggleError, isNull);
|
||||||
|
|
||||||
|
// 登出后到达的在途响应作废,不写入任何状态。
|
||||||
|
inFlight.complete(const LikeState(liked: true, likeCount: 7));
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(controller.feed, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late CommunityInteractionAnalytics analytics;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
events = [];
|
||||||
|
analytics = CommunityInteractionAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<String> names() => events.map((e) => e.$1).toList();
|
||||||
|
List<Map<String, dynamic>?> props() => events.map((e) => e.$2).toList();
|
||||||
|
|
||||||
|
test('互动四事件:source 逐字段(22 号白名单键集)', () {
|
||||||
|
analytics.postLiked(source: InteractionSource.feed);
|
||||||
|
analytics.postUnliked(source: InteractionSource.postDetail);
|
||||||
|
analytics.postFavorited(source: InteractionSource.postDetail);
|
||||||
|
analytics.postUnfavorited(source: InteractionSource.feed);
|
||||||
|
|
||||||
|
expect(names(), [
|
||||||
|
'post_liked',
|
||||||
|
'post_unliked',
|
||||||
|
'post_favorited',
|
||||||
|
'post_unfavorited',
|
||||||
|
]);
|
||||||
|
expect(props(), [
|
||||||
|
{'source': 'feed'},
|
||||||
|
{'source': 'post_detail'},
|
||||||
|
{'source': 'post_detail'},
|
||||||
|
{'source': 'feed'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comment_create_succeeded:durationMs/isReply/textLengthBucket', () {
|
||||||
|
analytics.commentCreateSucceeded(
|
||||||
|
durationMs: 4200,
|
||||||
|
isReply: false,
|
||||||
|
textLength: 12,
|
||||||
|
);
|
||||||
|
expect(names(), ['comment_create_succeeded']);
|
||||||
|
expect(props().single, {
|
||||||
|
'durationMs': 4200,
|
||||||
|
'isReply': false,
|
||||||
|
'textLengthBucket': 'short',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comment_create_failed:httpStatus 由五位业务码推导,网络错误缺席', () {
|
||||||
|
analytics.commentCreateFailed(
|
||||||
|
reason: CommentCreateFailureReason.validationError,
|
||||||
|
attemptSeq: 2,
|
||||||
|
errorCode: 40000,
|
||||||
|
);
|
||||||
|
analytics.commentCreateFailed(
|
||||||
|
reason: CommentCreateFailureReason.networkError,
|
||||||
|
attemptSeq: 3,
|
||||||
|
);
|
||||||
|
expect(names(), ['comment_create_failed', 'comment_create_failed']);
|
||||||
|
expect(props(), [
|
||||||
|
{
|
||||||
|
'failureReason': 'validation_error',
|
||||||
|
'attemptSeq': 2,
|
||||||
|
'errorCode': 40000,
|
||||||
|
'httpStatus': 400,
|
||||||
|
},
|
||||||
|
{'failureReason': 'network_error', 'attemptSeq': 3},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关注对:user_followed / user_unfollowed', () {
|
||||||
|
analytics.userFollowed(source: InteractionSource.postDetail);
|
||||||
|
analytics.userUnfollowed(source: InteractionSource.postDetail);
|
||||||
|
expect(names(), ['user_followed', 'user_unfollowed']);
|
||||||
|
expect(props(), [
|
||||||
|
{'source': 'post_detail'},
|
||||||
|
{'source': 'post_detail'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('textLengthBucketOf 分桶边界(06 §1.3 红线:不报精确字数)', () {
|
||||||
|
expect(textLengthBucketOf(0), 'empty');
|
||||||
|
expect(textLengthBucketOf(1), 'short');
|
||||||
|
expect(textLengthBucketOf(50), 'short');
|
||||||
|
expect(textLengthBucketOf(51), 'medium');
|
||||||
|
expect(textLengthBucketOf(500), 'medium');
|
||||||
|
expect(textLengthBucketOf(501), 'long');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('失败原因映射:网络归并口径 + 防枚举族归 not_found + 会话失效 null', () {
|
||||||
|
expect(
|
||||||
|
commentCreateFailureReasonOf(const ApiNetworkException()),
|
||||||
|
CommentCreateFailureReason.networkError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
commentCreateFailureReasonOf(const ApiRateLimitException()),
|
||||||
|
CommentCreateFailureReason.rateLimited,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
commentCreateFailureReasonOf(
|
||||||
|
const ApiBusinessException(code: ApiCodes.paramError, message: 'x'),
|
||||||
|
),
|
||||||
|
CommentCreateFailureReason.validationError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
commentCreateFailureReasonOf(
|
||||||
|
const ApiBusinessException(code: ApiCodes.postNotFound, message: 'x'),
|
||||||
|
),
|
||||||
|
CommentCreateFailureReason.notFound,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
commentCreateFailureReasonOf(
|
||||||
|
const ApiBusinessException(code: 50000, message: 'x'),
|
||||||
|
),
|
||||||
|
CommentCreateFailureReason.serverError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
commentCreateFailureReasonOf(const SessionExpiredException()),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
|
||||||
|
import '../../helpers/community_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('响应 DTO 逐字段映射(契约 v1.3.0)', () {
|
||||||
|
test('Post:完整形态含作者 / media / 互动计数 / publishedAt', () {
|
||||||
|
final post = Post.fromJson(samplePostJson());
|
||||||
|
|
||||||
|
expect(post.id, 'p-1');
|
||||||
|
expect(post.author.userId, 'u-1');
|
||||||
|
expect(post.author.nickname, '毛毛的铲屎官');
|
||||||
|
expect(post.petId, 'pet-1');
|
||||||
|
expect(post.category, PostCategory.general);
|
||||||
|
expect(post.title, '今天的豆豆');
|
||||||
|
expect(post.content, '晒了一下午太阳。');
|
||||||
|
expect(post.status, PostStatus.published);
|
||||||
|
expect(post.visibility, PostVisibility.public);
|
||||||
|
expect(post.media.single.assetId, 'a-1');
|
||||||
|
expect(post.media.single.isCover, isTrue);
|
||||||
|
expect(post.likeCount, 6);
|
||||||
|
expect(post.commentCount, 3);
|
||||||
|
expect(post.bookmarkCount, 2);
|
||||||
|
expect(post.likedByMe, isFalse);
|
||||||
|
expect(post.bookmarkedByMe, isFalse);
|
||||||
|
expect(post.publishedAt, isNotNull);
|
||||||
|
expect(post.version, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Post:draft 态 publishedAt 为 null(仅 published 非空)', () {
|
||||||
|
final post = Post.fromJson(samplePostJson(status: 'draft'));
|
||||||
|
expect(post.status, PostStatus.draft);
|
||||||
|
expect(post.publishedAt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FeedCard:卡片裁剪形态,纯文字帖 coverImage 为 null', () {
|
||||||
|
final withCover = FeedCard.fromJson(sampleFeedCardJson());
|
||||||
|
expect(withCover.coverImage!.url, contains('X-Amz-Signature'));
|
||||||
|
expect(withCover.mediaCount, 1);
|
||||||
|
expect(withCover.contentPreview, '晒了一下午太阳。');
|
||||||
|
expect(withCover.publishedAt, DateTime.parse('2026-09-08T10:05:00.000Z'));
|
||||||
|
|
||||||
|
final textOnly = FeedCard.fromJson(
|
||||||
|
sampleFeedCardJson()
|
||||||
|
..['coverImage'] = null
|
||||||
|
..['mediaCount'] = 0,
|
||||||
|
);
|
||||||
|
expect(textOnly.coverImage, isNull);
|
||||||
|
expect(textOnly.mediaCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('category:ai_creation(读侧预留)按线上 snake_case 解析', () {
|
||||||
|
final card = FeedCard.fromJson(
|
||||||
|
sampleFeedCardJson()..['category'] = 'ai_creation',
|
||||||
|
);
|
||||||
|
expect(card.category, PostCategory.aiCreation);
|
||||||
|
expect(PostCategory.aiCreation.wire, 'ai_creation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AuthorSummary:nickname 与 avatarUrl 同为 null 即降级/墓碑形态', () {
|
||||||
|
final normal = AuthorSummary.fromJson(sampleAuthorJson());
|
||||||
|
expect(normal.isDegraded, isFalse);
|
||||||
|
|
||||||
|
final degraded = AuthorSummary.fromJson(
|
||||||
|
sampleAuthorJson(nickname: null, avatarUrl: null),
|
||||||
|
);
|
||||||
|
expect(degraded.userId, 'u-1');
|
||||||
|
expect(degraded.isDegraded, isTrue);
|
||||||
|
|
||||||
|
// 仅缺头像不算降级(无头像 / 头像非 ready 也是 null)。
|
||||||
|
final noAvatar = AuthorSummary.fromJson(
|
||||||
|
sampleAuthorJson(avatarUrl: null),
|
||||||
|
);
|
||||||
|
expect(noAvatar.isDegraded, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PostComment:replyToUser 非回复为 null,@ 回复含降级形态', () {
|
||||||
|
final plain = PostComment.fromJson(sampleCommentJson());
|
||||||
|
expect(plain.replyToUser, isNull);
|
||||||
|
expect(plain.postId, 'p-1');
|
||||||
|
|
||||||
|
final reply = PostComment.fromJson(
|
||||||
|
sampleCommentJson(
|
||||||
|
replyToUser: sampleAuthorJson(
|
||||||
|
userId: 'u-2',
|
||||||
|
nickname: null,
|
||||||
|
avatarUrl: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(reply.replyToUser!.userId, 'u-2');
|
||||||
|
expect(reply.replyToUser!.isDegraded, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MediaUploadCredentials:requiredHeaders 原样映射为字符串表', () {
|
||||||
|
final credentials = MediaUploadCredentials.fromJson(
|
||||||
|
sampleUploadCredentialsJson(),
|
||||||
|
);
|
||||||
|
expect(credentials.assetId, 'a-1');
|
||||||
|
expect(credentials.method, 'PUT');
|
||||||
|
expect(credentials.requiredHeaders, {'Content-Type': 'image/jpeg'});
|
||||||
|
expect(credentials.expiresAt, DateTime.parse('2026-09-08T10:10:00.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MediaAsset:ready 态 url/readyAt 非空,uploading 态为 null', () {
|
||||||
|
final ready = MediaAsset.fromJson(sampleMediaAssetJson());
|
||||||
|
expect(ready.status, MediaAssetStatus.ready);
|
||||||
|
expect(ready.url, isNotNull);
|
||||||
|
expect(ready.readyAt, isNotNull);
|
||||||
|
expect(ready.byteSize, 204800);
|
||||||
|
|
||||||
|
final uploading = MediaAsset.fromJson(
|
||||||
|
sampleMediaAssetJson(status: 'uploading'),
|
||||||
|
);
|
||||||
|
expect(uploading.status, MediaAssetStatus.uploading);
|
||||||
|
expect(uploading.url, isNull);
|
||||||
|
expect(uploading.readyAt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LikeState / BookmarkState / FollowState / FollowStats 终态解析', () {
|
||||||
|
final like = LikeState.fromJson({'liked': true, 'likeCount': 7});
|
||||||
|
expect(like.liked, isTrue);
|
||||||
|
expect(like.likeCount, 7);
|
||||||
|
|
||||||
|
final bookmark = BookmarkState.fromJson({
|
||||||
|
'bookmarked': false,
|
||||||
|
'bookmarkCount': 0,
|
||||||
|
});
|
||||||
|
expect(bookmark.bookmarked, isFalse);
|
||||||
|
expect(bookmark.bookmarkCount, 0);
|
||||||
|
|
||||||
|
final follow = FollowState.fromJson({
|
||||||
|
'following': true,
|
||||||
|
'followerCount': 12,
|
||||||
|
});
|
||||||
|
expect(follow.following, isTrue);
|
||||||
|
expect(follow.followerCount, 12);
|
||||||
|
|
||||||
|
final stats = FollowStats.fromJson({
|
||||||
|
'followerCount': 12,
|
||||||
|
'followingCount': 34,
|
||||||
|
'followedByMe': false,
|
||||||
|
});
|
||||||
|
expect(stats.followingCount, 34);
|
||||||
|
expect(stats.followedByMe, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未知枚举取值抛 FormatException(契约漂移测试期暴露)', () {
|
||||||
|
expect(
|
||||||
|
() => Post.fromJson(samplePostJson()..['status'] = 'hidden'),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => FeedCard.fromJson(sampleFeedCardJson()..['category'] = 'topic'),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => MediaAsset.fromJson(sampleMediaAssetJson(status: 'deleted')),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => Post.fromJson(samplePostJson()..['visibility'] = 'private'),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('请求 DTO 序列化', () {
|
||||||
|
test('CreatePostRequest:可选字段缺席不出现,media 按项序列化', () {
|
||||||
|
const minimal = CreatePostRequest(content: '纯文字帖');
|
||||||
|
expect(minimal.toJson(), {'content': '纯文字帖'});
|
||||||
|
|
||||||
|
const full = CreatePostRequest(
|
||||||
|
content: '正文',
|
||||||
|
title: '标题',
|
||||||
|
category: PostCategory.help,
|
||||||
|
status: PostStatus.published,
|
||||||
|
petId: 'pet-1',
|
||||||
|
media: [
|
||||||
|
PostMediaAttachRequest(assetId: 'a-1', position: 0, isCover: true),
|
||||||
|
PostMediaAttachRequest(assetId: 'a-2', position: 1, caption: '第二张'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(full.toJson(), {
|
||||||
|
'content': '正文',
|
||||||
|
'title': '标题',
|
||||||
|
'category': 'help',
|
||||||
|
'status': 'published',
|
||||||
|
'petId': 'pet-1',
|
||||||
|
'media': [
|
||||||
|
{'assetId': 'a-1', 'position': 0, 'isCover': true},
|
||||||
|
{'assetId': 'a-2', 'position': 1, 'caption': '第二张'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UpdatePostRequest:media 三态——缺席不动 / [] 清空 / 非空整组替换', () {
|
||||||
|
const absent = UpdatePostRequest(version: 2, title: '改标题');
|
||||||
|
expect(absent.toJson(), {'version': 2, 'title': '改标题'});
|
||||||
|
expect(absent.toJson().containsKey('media'), isFalse);
|
||||||
|
|
||||||
|
const clear = UpdatePostRequest(version: 2, media: []);
|
||||||
|
expect(clear.toJson(), {'version': 2, 'media': <Object?>[]});
|
||||||
|
|
||||||
|
const replace = UpdatePostRequest(
|
||||||
|
version: 2,
|
||||||
|
media: [PostMediaAttachRequest(assetId: 'a-3')],
|
||||||
|
);
|
||||||
|
expect(replace.toJson()['media'], [
|
||||||
|
{'assetId': 'a-3'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UpdatePostRequest:publish 即 status: published(唯一开放迁移)', () {
|
||||||
|
const publish = UpdatePostRequest(version: 1, publish: true);
|
||||||
|
expect(publish.toJson(), {'version': 1, 'status': 'published'});
|
||||||
|
|
||||||
|
const noPublish = UpdatePostRequest(version: 1, content: '改正文');
|
||||||
|
expect(noPublish.toJson().containsKey('status'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CreateMediaUploadRequest:kind/purpose 按线上取值,sha256 可选', () {
|
||||||
|
const request = CreateMediaUploadRequest(
|
||||||
|
kind: MediaKind.image,
|
||||||
|
purpose: MediaPurpose.postImage,
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
byteSize: 204800,
|
||||||
|
);
|
||||||
|
expect(request.toJson(), {
|
||||||
|
'kind': 'image',
|
||||||
|
'purpose': 'post_image',
|
||||||
|
'mimeType': 'image/jpeg',
|
||||||
|
'byteSize': 204800,
|
||||||
|
});
|
||||||
|
|
||||||
|
const withHash = CreateMediaUploadRequest(
|
||||||
|
kind: MediaKind.image,
|
||||||
|
purpose: MediaPurpose.postImage,
|
||||||
|
mimeType: 'image/webp',
|
||||||
|
byteSize: 1,
|
||||||
|
sha256:
|
||||||
|
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
);
|
||||||
|
expect(withHash.toJson()['sha256'], hasLength(64));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CreateCommentRequest:replyToUserId 缺席不出现', () {
|
||||||
|
expect(const CreateCommentRequest(content: '好可爱!').toJson(), {
|
||||||
|
'content': '好可爱!',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
const CreateCommentRequest(
|
||||||
|
content: '@回复',
|
||||||
|
replyToUserId: 'u-2',
|
||||||
|
).toJson(),
|
||||||
|
{'content': '@回复', 'replyToUserId': 'u-2'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('互动字段副本更新', () {
|
||||||
|
test('Post.copyWithInteraction 只动互动字段', () {
|
||||||
|
final post = Post.fromJson(samplePostJson());
|
||||||
|
final updated = post.copyWithInteraction(likedByMe: true, likeCount: 7);
|
||||||
|
expect(updated.likedByMe, isTrue);
|
||||||
|
expect(updated.likeCount, 7);
|
||||||
|
expect(updated.bookmarkCount, post.bookmarkCount);
|
||||||
|
expect(updated.content, post.content);
|
||||||
|
expect(updated.version, post.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FeedCard.copyWithInteraction 只动互动字段', () {
|
||||||
|
final card = sampleFeedCard();
|
||||||
|
final updated = card.copyWithInteraction(
|
||||||
|
bookmarkedByMe: true,
|
||||||
|
bookmarkCount: 3,
|
||||||
|
);
|
||||||
|
expect(updated.bookmarkedByMe, isTrue);
|
||||||
|
expect(updated.bookmarkCount, 3);
|
||||||
|
expect(updated.likeCount, card.likeCount);
|
||||||
|
expect(updated.contentPreview, card.contentPreview);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/token_refresher.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
|
||||||
|
import '../../helpers/auth_test_helpers.dart';
|
||||||
|
import '../../helpers/community_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late SessionManager session;
|
||||||
|
late FakeHttpAdapter adapter;
|
||||||
|
late FakeHttpAdapter mediaAdapter;
|
||||||
|
late ApiCommunityRepository repository;
|
||||||
|
|
||||||
|
Future<void> setUpWith(
|
||||||
|
Future<ResponseBody> Function(RequestOptions) handler,
|
||||||
|
) async {
|
||||||
|
session = SessionManager(store: InMemoryTokenStore());
|
||||||
|
await session.updateTokens(sampleTokens(access: 'community-access'));
|
||||||
|
final dio = buildPatbondDio(
|
||||||
|
session: session,
|
||||||
|
baseUrl: 'http://community.local',
|
||||||
|
);
|
||||||
|
adapter = FakeHttpAdapter(handler);
|
||||||
|
dio.httpClientAdapter = adapter;
|
||||||
|
final refresher = TokenRefresher(dio: dio, session: session);
|
||||||
|
// media 两步上传端点由 user 服务提供(13 号报告 §2),走独立客户端;
|
||||||
|
// 两 adapter 分开记录以断言线路不串。
|
||||||
|
final mediaDio = buildPatbondDio(
|
||||||
|
session: session,
|
||||||
|
baseUrl: 'http://user.local',
|
||||||
|
);
|
||||||
|
mediaAdapter = FakeHttpAdapter(handler);
|
||||||
|
mediaDio.httpClientAdapter = mediaAdapter;
|
||||||
|
repository = ApiCommunityRepository(
|
||||||
|
api: ApiClient(dio: dio, session: session, refresher: refresher),
|
||||||
|
mediaApi: ApiClient(
|
||||||
|
dio: mediaDio,
|
||||||
|
session: session,
|
||||||
|
refresher: refresher,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('请求线路(路径 / 方法 / 鉴权 / 参数)', () {
|
||||||
|
test(
|
||||||
|
'createMediaUpload:POST /api/v1/media/uploads 走 user 服务客户端,携带 Bearer',
|
||||||
|
() async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async =>
|
||||||
|
jsonResponse(201, okEnvelope(sampleUploadCredentialsJson())),
|
||||||
|
);
|
||||||
|
|
||||||
|
final credentials = await repository.createMediaUpload(
|
||||||
|
const CreateMediaUploadRequest(
|
||||||
|
kind: MediaKind.image,
|
||||||
|
purpose: MediaPurpose.postImage,
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
byteSize: 204800,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 线路:media 端点不打 community 客户端。
|
||||||
|
expect(adapter.requests, isEmpty);
|
||||||
|
final request = mediaAdapter.requests.single;
|
||||||
|
expect(request.baseUrl, 'http://user.local');
|
||||||
|
expect(request.path, '/api/v1/media/uploads');
|
||||||
|
expect(request.method, 'POST');
|
||||||
|
expect(request.headers['Authorization'], 'Bearer community-access');
|
||||||
|
expect(request.data, {
|
||||||
|
'kind': 'image',
|
||||||
|
'purpose': 'post_image',
|
||||||
|
'mimeType': 'image/jpeg',
|
||||||
|
'byteSize': 204800,
|
||||||
|
});
|
||||||
|
expect(credentials.uploadUrl, contains('X-Amz-Signature'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('completeMediaUpload:POST /complete 无请求体(服务端幂等)', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async =>
|
||||||
|
jsonResponse(200, okEnvelope(sampleMediaAssetJson())),
|
||||||
|
);
|
||||||
|
|
||||||
|
final asset = await repository.completeMediaUpload('a-1');
|
||||||
|
|
||||||
|
expect(adapter.requests, isEmpty);
|
||||||
|
final request = mediaAdapter.requests.single;
|
||||||
|
expect(request.path, '/api/v1/media/uploads/a-1/complete');
|
||||||
|
expect(request.method, 'POST');
|
||||||
|
expect(asset.status, MediaAssetStatus.ready);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('帖子 CRUD:POST / GET / PATCH / DELETE 线路', () async {
|
||||||
|
var call = 0;
|
||||||
|
await setUpWith((options) async {
|
||||||
|
call += 1;
|
||||||
|
return switch (call) {
|
||||||
|
1 => jsonResponse(201, okEnvelope(samplePostJson())),
|
||||||
|
2 => jsonResponse(200, okEnvelope(samplePostJson())),
|
||||||
|
3 => jsonResponse(200, okEnvelope(samplePostJson(version: 2))),
|
||||||
|
_ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await repository.createPost(const CreatePostRequest(content: '正文'));
|
||||||
|
await repository.getPost('p-1');
|
||||||
|
final updated = await repository.updatePost(
|
||||||
|
'p-1',
|
||||||
|
const UpdatePostRequest(version: 1, publish: true),
|
||||||
|
);
|
||||||
|
await repository.deletePost('p-1');
|
||||||
|
|
||||||
|
expect(adapter.requests[0].path, '/api/v1/posts');
|
||||||
|
expect(adapter.requests[0].method, 'POST');
|
||||||
|
expect(adapter.requests[1].path, '/api/v1/posts/p-1');
|
||||||
|
expect(adapter.requests[1].method, 'GET');
|
||||||
|
expect(adapter.requests[2].method, 'PATCH');
|
||||||
|
expect(adapter.requests[2].data, {'version': 1, 'status': 'published'});
|
||||||
|
expect(adapter.requests[3].method, 'DELETE');
|
||||||
|
expect(updated.version, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listMyPosts:/api/v1/me/posts 分页 + status 过滤,缺省不传', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope(
|
||||||
|
cursorPageJson([samplePostJson()], nextCursor: 'c2', hasMore: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final page = await repository.listMyPosts(
|
||||||
|
limit: 20,
|
||||||
|
cursor: 'c1',
|
||||||
|
status: PostStatus.draft,
|
||||||
|
);
|
||||||
|
await repository.listMyPosts();
|
||||||
|
|
||||||
|
expect(adapter.requests[0].path, '/api/v1/me/posts');
|
||||||
|
expect(adapter.requests[0].queryParameters, {
|
||||||
|
'limit': 20,
|
||||||
|
'cursor': 'c1',
|
||||||
|
'status': 'draft',
|
||||||
|
});
|
||||||
|
expect(adapter.requests[1].queryParameters, isEmpty);
|
||||||
|
expect(page.items.single.id, 'p-1');
|
||||||
|
expect(page.nextCursor, 'c2');
|
||||||
|
expect(page.hasMore, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getFeed:/api/v1/feed 游标分页与 FeedCard 信封解析', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope(
|
||||||
|
cursorPageJson(
|
||||||
|
[sampleFeedCardJson()],
|
||||||
|
nextCursor: 'f2',
|
||||||
|
hasMore: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final page = await repository.getFeed(limit: 20, cursor: 'f1');
|
||||||
|
|
||||||
|
final request = adapter.requests.single;
|
||||||
|
expect(request.path, '/api/v1/feed');
|
||||||
|
expect(request.queryParameters, {'limit': 20, 'cursor': 'f1'});
|
||||||
|
expect(page.items.single.contentPreview, '晒了一下午太阳。');
|
||||||
|
expect(page.nextCursor, 'f2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('评论:列表分页、创建、顶层短路径删除', () async {
|
||||||
|
var call = 0;
|
||||||
|
await setUpWith((options) async {
|
||||||
|
call += 1;
|
||||||
|
return switch (call) {
|
||||||
|
1 => jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope(cursorPageJson([sampleCommentJson()])),
|
||||||
|
),
|
||||||
|
2 => jsonResponse(201, okEnvelope(sampleCommentJson())),
|
||||||
|
_ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
final page = await repository.listComments('p-1', limit: 20);
|
||||||
|
await repository.createComment(
|
||||||
|
'p-1',
|
||||||
|
const CreateCommentRequest(content: '好可爱!'),
|
||||||
|
);
|
||||||
|
await repository.deleteComment('c-1');
|
||||||
|
|
||||||
|
expect(adapter.requests[0].path, '/api/v1/posts/p-1/comments');
|
||||||
|
expect(adapter.requests[0].queryParameters, {'limit': 20});
|
||||||
|
expect(adapter.requests[1].method, 'POST');
|
||||||
|
expect(adapter.requests[1].data, {'content': '好可爱!'});
|
||||||
|
expect(adapter.requests[2].path, '/api/v1/comments/c-1');
|
||||||
|
expect(adapter.requests[2].method, 'DELETE');
|
||||||
|
expect(page.items.single.content, '好可爱!');
|
||||||
|
expect(page.hasMore, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('点赞/收藏:PUT 与 DELETE 同路径,返回权威终态', () async {
|
||||||
|
var call = 0;
|
||||||
|
await setUpWith((options) async {
|
||||||
|
call += 1;
|
||||||
|
return switch (call) {
|
||||||
|
1 => jsonResponse(200, okEnvelope({'liked': true, 'likeCount': 7})),
|
||||||
|
2 => jsonResponse(200, okEnvelope({'liked': false, 'likeCount': 6})),
|
||||||
|
3 => jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope({'bookmarked': true, 'bookmarkCount': 3}),
|
||||||
|
),
|
||||||
|
_ => jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope({'bookmarked': false, 'bookmarkCount': 2}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
final liked = await repository.likePost('p-1');
|
||||||
|
final unliked = await repository.unlikePost('p-1');
|
||||||
|
final bookmarked = await repository.bookmarkPost('p-1');
|
||||||
|
await repository.unbookmarkPost('p-1');
|
||||||
|
|
||||||
|
expect(adapter.requests[0].path, '/api/v1/posts/p-1/like');
|
||||||
|
expect(adapter.requests[0].method, 'PUT');
|
||||||
|
expect(adapter.requests[1].path, '/api/v1/posts/p-1/like');
|
||||||
|
expect(adapter.requests[1].method, 'DELETE');
|
||||||
|
expect(adapter.requests[2].path, '/api/v1/posts/p-1/bookmark');
|
||||||
|
expect(adapter.requests[2].method, 'PUT');
|
||||||
|
expect(adapter.requests[3].method, 'DELETE');
|
||||||
|
// PUT/DELETE 语义幂等:无 Idempotency-Key。
|
||||||
|
for (final request in adapter.requests) {
|
||||||
|
expect(request.headers.containsKey('Idempotency-Key'), isFalse);
|
||||||
|
}
|
||||||
|
expect(liked.liked, isTrue);
|
||||||
|
expect(liked.likeCount, 7);
|
||||||
|
expect(unliked.liked, isFalse);
|
||||||
|
expect(bookmarked.bookmarkCount, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listMyBookmarks:/api/v1/me/bookmarks,项形态 = FeedCard', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope(cursorPageJson([sampleFeedCardJson()])),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final page = await repository.listMyBookmarks(cursor: 'b1');
|
||||||
|
|
||||||
|
expect(adapter.requests.single.path, '/api/v1/me/bookmarks');
|
||||||
|
expect(adapter.requests.single.queryParameters, {'cursor': 'b1'});
|
||||||
|
expect(page.items.single.id, 'p-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关注:PUT / DELETE / follow-stats 线路与终态', () async {
|
||||||
|
var call = 0;
|
||||||
|
await setUpWith((options) async {
|
||||||
|
call += 1;
|
||||||
|
return switch (call) {
|
||||||
|
1 => jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope({'following': true, 'followerCount': 12}),
|
||||||
|
),
|
||||||
|
2 => jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope({'following': false, 'followerCount': 11}),
|
||||||
|
),
|
||||||
|
_ => jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope({
|
||||||
|
'followerCount': 11,
|
||||||
|
'followingCount': 34,
|
||||||
|
'followedByMe': false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
final followed = await repository.followUser('u-2');
|
||||||
|
final unfollowed = await repository.unfollowUser('u-2');
|
||||||
|
final stats = await repository.getFollowStats('u-2');
|
||||||
|
|
||||||
|
expect(adapter.requests[0].path, '/api/v1/users/u-2/follow');
|
||||||
|
expect(adapter.requests[0].method, 'PUT');
|
||||||
|
expect(adapter.requests[1].method, 'DELETE');
|
||||||
|
expect(adapter.requests[2].path, '/api/v1/users/u-2/follow-stats');
|
||||||
|
expect(adapter.requests[2].method, 'GET');
|
||||||
|
expect(followed.following, isTrue);
|
||||||
|
expect(unfollowed.followerCount, 11);
|
||||||
|
expect(stats.followingCount, 34);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Idempotency-Key(community 域必带语义)', () {
|
||||||
|
test('createPost / createComment 必带键,每次逻辑提交换新键', () async {
|
||||||
|
var call = 0;
|
||||||
|
await setUpWith((options) async {
|
||||||
|
call += 1;
|
||||||
|
return call <= 2
|
||||||
|
? jsonResponse(201, okEnvelope(samplePostJson()))
|
||||||
|
: jsonResponse(201, okEnvelope(sampleCommentJson()));
|
||||||
|
});
|
||||||
|
|
||||||
|
await repository.createPost(const CreatePostRequest(content: '一'));
|
||||||
|
await repository.createPost(const CreatePostRequest(content: '二'));
|
||||||
|
await repository.createComment(
|
||||||
|
'p-1',
|
||||||
|
const CreateCommentRequest(content: '三'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final keys = adapter.requests
|
||||||
|
.map((r) => r.headers['Idempotency-Key'] as String?)
|
||||||
|
.toList();
|
||||||
|
expect(keys, everyElement(isNotNull));
|
||||||
|
expect(keys, everyElement(isNotEmpty));
|
||||||
|
// 每次逻辑提交换新键(契约:1~128 字符,建议 UUID)。
|
||||||
|
expect(keys.toSet().length, 3);
|
||||||
|
expect(keys.every((key) => key!.length <= 128), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET / PATCH / DELETE 不带 Idempotency-Key', () async {
|
||||||
|
var call = 0;
|
||||||
|
await setUpWith((options) async {
|
||||||
|
call += 1;
|
||||||
|
return switch (call) {
|
||||||
|
1 => jsonResponse(200, okEnvelope(samplePostJson())),
|
||||||
|
2 => jsonResponse(200, okEnvelope(samplePostJson(version: 2))),
|
||||||
|
_ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await repository.getPost('p-1');
|
||||||
|
await repository.updatePost('p-1', const UpdatePostRequest(version: 1));
|
||||||
|
await repository.deletePost('p-1');
|
||||||
|
|
||||||
|
for (final request in adapter.requests) {
|
||||||
|
expect(request.headers.containsKey('Idempotency-Key'), isFalse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40101:单飞刷新后重放,重放沿用同一 Idempotency-Key', () async {
|
||||||
|
await setUpWith((options) async {
|
||||||
|
if (options.path == '/api/v1/auth/refresh') {
|
||||||
|
return jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope(tokenDataJson(access: 'new-access')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.headers['Authorization'] == 'Bearer community-access') {
|
||||||
|
return jsonResponse(401, errorEnvelope(40101, 'token 过期'));
|
||||||
|
}
|
||||||
|
return jsonResponse(201, okEnvelope(samplePostJson()));
|
||||||
|
});
|
||||||
|
|
||||||
|
await repository.createPost(const CreatePostRequest(content: '正文'));
|
||||||
|
|
||||||
|
final postRequests = adapter.requests
|
||||||
|
.where((r) => r.path == '/api/v1/posts')
|
||||||
|
.toList();
|
||||||
|
expect(postRequests, hasLength(2));
|
||||||
|
expect(postRequests.last.headers['Authorization'], 'Bearer new-access');
|
||||||
|
// 刷新重放是同一逻辑提交:同键命中服务端首次结果,不重复建帖。
|
||||||
|
expect(
|
||||||
|
postRequests.first.headers['Idempotency-Key'],
|
||||||
|
postRequests.last.headers['Idempotency-Key'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('错误码 → 类型化异常映射(v1.3.0 新增 9 码 + 40902)', () {
|
||||||
|
Future<void> expectMapped(int httpStatus, int code, Matcher matcher) async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(httpStatus, errorEnvelope(code)),
|
||||||
|
);
|
||||||
|
await expectLater(repository.getPost('p-x'), throwsA(matcher));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('40301 → PostAccessDeniedException', () async {
|
||||||
|
await expectMapped(403, 40301, isA<PostAccessDeniedException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40403 → PostNotFoundException(防枚举合并)', () async {
|
||||||
|
await expectMapped(404, 40403, isA<PostNotFoundException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40404 → CommentNotFoundException', () async {
|
||||||
|
await expectMapped(404, 40404, isA<CommentNotFoundException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40405 → MediaAssetNotFoundException', () async {
|
||||||
|
await expectMapped(404, 40405, isA<MediaAssetNotFoundException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40406 → CommunityUserNotFoundException', () async {
|
||||||
|
await expectMapped(404, 40406, isA<CommunityUserNotFoundException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40902 → PostVersionConflictException(共码独立类型)', () async {
|
||||||
|
await expectMapped(409, 40902, isA<PostVersionConflictException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40905 → IdempotencyMismatchException', () async {
|
||||||
|
await expectMapped(409, 40905, isA<IdempotencyMismatchException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('42203 → MediaNotReadyException', () async {
|
||||||
|
await expectMapped(422, 42203, isA<MediaNotReadyException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('42204 → SelfFollowException', () async {
|
||||||
|
await expectMapped(422, 42204, isA<SelfFollowException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('42205 → MediaUploadStateException', () async {
|
||||||
|
await expectMapped(422, 42205, isA<MediaUploadStateException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40401(pets 域码)不升格,保持通用 ApiBusinessException', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(404, errorEnvelope(40401, '宠物不存在')),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
repository.createPost(const CreatePostRequest(content: '带宠物')),
|
||||||
|
throwsA(
|
||||||
|
isA<ApiBusinessException>()
|
||||||
|
.having((e) => e.code, 'code', 40401)
|
||||||
|
.having((e) => e, 'type', isNot(isA<PostNotFoundException>())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('类型化异常仍可按基类 ApiBusinessException 捕获', () {
|
||||||
|
const error = SelfFollowException(message: '不能关注自己');
|
||||||
|
expect(error, isA<ApiBusinessException>());
|
||||||
|
expect(error.code, ApiCodes.selfFollow);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('community 服务基地址常量存在且默认指向 :8084', () {
|
||||||
|
expect(patbondCommunityApiBaseUrl, 'http://127.0.0.1:8084');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late FeedAnalytics analytics;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
events = [];
|
||||||
|
analytics = FeedAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('feed_viewed:五个专有属性逐一上报(06 §1.4 白名单)', () {
|
||||||
|
analytics.feedViewed(
|
||||||
|
feedTab: FeedTab.home,
|
||||||
|
durationMs: 12345,
|
||||||
|
impressionCount: 7,
|
||||||
|
loadMoreCount: 2,
|
||||||
|
refreshCount: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events, hasLength(1));
|
||||||
|
expect(events.single.$1, 'feed_viewed');
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'feedTab': 'home',
|
||||||
|
'durationMs': 12345,
|
||||||
|
'impressionCount': 7,
|
||||||
|
'loadMoreCount': 2,
|
||||||
|
'refreshCount': 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('feed_load_failed:业务码带 errorCode 并推导 httpStatus', () {
|
||||||
|
analytics.feedLoadFailed(
|
||||||
|
feedTab: FeedTab.home,
|
||||||
|
loadType: FeedLoadType.loadMore,
|
||||||
|
reason: FeedLoadFailureReason.serverError,
|
||||||
|
errorCode: 40403,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$1, 'feed_load_failed');
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'feedTab': 'home',
|
||||||
|
'loadType': 'load_more',
|
||||||
|
'failureReason': 'server_error',
|
||||||
|
'errorCode': 40403,
|
||||||
|
'httpStatus': 404,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('feed_load_failed:网络错误缺席 errorCode/httpStatus', () {
|
||||||
|
analytics.feedLoadFailedFrom(
|
||||||
|
const ApiNetworkException(),
|
||||||
|
feedTab: FeedTab.home,
|
||||||
|
loadType: FeedLoadType.refresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'feedTab': 'home',
|
||||||
|
'loadType': 'refresh',
|
||||||
|
'failureReason': 'network_error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异常映射:限流/网络/业务兜底;会话失效不上报', () {
|
||||||
|
expect(
|
||||||
|
feedLoadFailureReasonOf(const ApiRateLimitException()),
|
||||||
|
FeedLoadFailureReason.rateLimited,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
feedLoadFailureReasonOf(const ApiNetworkException()),
|
||||||
|
FeedLoadFailureReason.networkError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
feedLoadFailureReasonOf(
|
||||||
|
const ApiBusinessException(code: 40000, message: 'x'),
|
||||||
|
),
|
||||||
|
FeedLoadFailureReason.serverError,
|
||||||
|
);
|
||||||
|
expect(feedLoadFailureReasonOf(const SessionExpiredException()), isNull);
|
||||||
|
|
||||||
|
analytics.feedLoadFailedFrom(
|
||||||
|
const SessionExpiredException(),
|
||||||
|
feedTab: FeedTab.home,
|
||||||
|
loadType: FeedLoadType.refresh,
|
||||||
|
);
|
||||||
|
expect(events, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:fake_async/fake_async.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/feed_exposure.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('曝光判定:≥50% 驻留满 500ms 记一次,段内按帖去重', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
|
||||||
|
segment.updateVisibility('p-1', 0.8);
|
||||||
|
async.elapse(const Duration(milliseconds: 600));
|
||||||
|
expect(segment.impressionCount, 1);
|
||||||
|
|
||||||
|
// 再次可见不重复计。
|
||||||
|
segment.updateVisibility('p-1', 0.9);
|
||||||
|
async.elapse(const Duration(milliseconds: 600));
|
||||||
|
expect(segment.impressionCount, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('快速滑过(<500ms 跌破阈值)不计曝光', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
|
||||||
|
segment.updateVisibility('p-1', 0.8);
|
||||||
|
async.elapse(const Duration(milliseconds: 300));
|
||||||
|
segment.updateVisibility('p-1', 0.2); // 滚出视口
|
||||||
|
async.elapse(const Duration(seconds: 1));
|
||||||
|
expect(segment.impressionCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('可见面积不足 50% 不起计时', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
|
||||||
|
segment.updateVisibility('p-1', 0.49);
|
||||||
|
async.elapse(const Duration(seconds: 1));
|
||||||
|
expect(segment.impressionCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('结算:冻结计数、取消在途驻留、幂等只出一份', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final clock = async.getClock(DateTime(2026));
|
||||||
|
final segment = FeedViewSegment(now: clock.now);
|
||||||
|
segment.updateVisibility('p-1', 1);
|
||||||
|
async.elapse(const Duration(milliseconds: 600));
|
||||||
|
segment.updateVisibility('p-2', 1); // 在途驻留,结算时未满 500ms
|
||||||
|
async.elapse(const Duration(milliseconds: 100));
|
||||||
|
segment.recordRefresh();
|
||||||
|
segment.recordLoadMore();
|
||||||
|
segment.recordLoadMore();
|
||||||
|
|
||||||
|
final summary = segment.settle();
|
||||||
|
expect(summary, isNotNull);
|
||||||
|
expect(summary!.impressionCount, 1);
|
||||||
|
expect(summary.refreshCount, 1);
|
||||||
|
expect(summary.loadMoreCount, 2);
|
||||||
|
expect(summary.durationMs, 700);
|
||||||
|
|
||||||
|
// 幂等:二次结算无第二条;结算后计数与曝光全部作废。
|
||||||
|
expect(segment.settle(), isNull);
|
||||||
|
segment.updateVisibility('p-3', 1);
|
||||||
|
async.elapse(const Duration(seconds: 1));
|
||||||
|
expect(segment.impressionCount, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('durationMs 上限截断 30 分钟(防挂机污染 H8)', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
|
||||||
|
async.elapse(const Duration(minutes: 45));
|
||||||
|
expect(
|
||||||
|
segment.settle()!.durationMs,
|
||||||
|
const Duration(minutes: 30).inMilliseconds,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
|
||||||
|
|
||||||
|
/// 预签名直传客户端(T3-13):本地 HttpServer 模拟 MinIO
|
||||||
|
/// (埋点队列测试先例)——200 成功、403 签名过期、传输中断三线路。
|
||||||
|
void main() {
|
||||||
|
Future<HttpServer> startServer(
|
||||||
|
Future<void> Function(HttpRequest request) handler,
|
||||||
|
) async {
|
||||||
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
server.listen((request) async => handler(request));
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('200:PUT 本体逐字节到达,requiredHeaders 原样携带,无鉴权头泄漏', () async {
|
||||||
|
late Map<String, String?> seenHeaders;
|
||||||
|
late List<int> seenBody;
|
||||||
|
late String seenMethod;
|
||||||
|
final server = await startServer((request) async {
|
||||||
|
seenMethod = request.method;
|
||||||
|
seenHeaders = {
|
||||||
|
'content-type': request.headers.value('content-type'),
|
||||||
|
'authorization': request.headers.value('authorization'),
|
||||||
|
'x-device-id': request.headers.value('x-device-id'),
|
||||||
|
};
|
||||||
|
seenBody = await request.fold<List<int>>(
|
||||||
|
[],
|
||||||
|
(all, chunk) => all..addAll(chunk),
|
||||||
|
);
|
||||||
|
request.response.statusCode = 200;
|
||||||
|
await request.response.close();
|
||||||
|
});
|
||||||
|
addTearDown(() => server.close(force: true));
|
||||||
|
|
||||||
|
final bytes = Uint8List.fromList(List.generate(4096, (i) => i % 251));
|
||||||
|
final progress = <(int, int)>[];
|
||||||
|
await DioMediaDirectUploadClient().put(
|
||||||
|
url:
|
||||||
|
'http://127.0.0.1:${server.port}/patbond-media/post_image/a-1?X-Amz-Signature=sig',
|
||||||
|
headers: const {'Content-Type': 'image/jpeg'},
|
||||||
|
bytes: bytes,
|
||||||
|
onProgress: (sent, total) => progress.add((sent, total)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(seenMethod, 'PUT');
|
||||||
|
expect(seenBody, bytes);
|
||||||
|
// Content-Type 已签进签名,必须原样携带。
|
||||||
|
expect(seenHeaders['content-type'], 'image/jpeg');
|
||||||
|
// 裸客户端:预签名 URL 即鉴权,业务侧 Bearer/设备头不得外漏给存储。
|
||||||
|
expect(seenHeaders['authorization'], isNull);
|
||||||
|
expect(seenHeaders['x-device-id'], isNull);
|
||||||
|
expect(progress.last.$1, progress.last.$2);
|
||||||
|
expect(progress.last.$2, bytes.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('403(签名过期/被改动)→ isCredentialRejected 异常', () async {
|
||||||
|
final server = await startServer((request) async {
|
||||||
|
await request.drain<void>();
|
||||||
|
request.response.statusCode = 403;
|
||||||
|
await request.response.close();
|
||||||
|
});
|
||||||
|
addTearDown(() => server.close(force: true));
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
DioMediaDirectUploadClient().put(
|
||||||
|
url: 'http://127.0.0.1:${server.port}/patbond-media/a-1',
|
||||||
|
headers: const {'Content-Type': 'image/jpeg'},
|
||||||
|
bytes: Uint8List(16),
|
||||||
|
),
|
||||||
|
throwsA(
|
||||||
|
isA<MediaDirectUploadException>()
|
||||||
|
.having((e) => e.statusCode, 'statusCode', 403)
|
||||||
|
.having(
|
||||||
|
(e) => e.isCredentialRejected,
|
||||||
|
'isCredentialRejected',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('传输中断(服务端半途断连)→ 网络型异常(statusCode null,可重试)', () async {
|
||||||
|
final server = await startServer((request) async {
|
||||||
|
// 读取少量字节后直接断开 socket,不回任何响应。
|
||||||
|
await request.take(1).drain<void>();
|
||||||
|
final socket = await request.response.detachSocket(writeHeaders: false);
|
||||||
|
await socket.close();
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
addTearDown(() => server.close(force: true));
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
DioMediaDirectUploadClient().put(
|
||||||
|
url: 'http://127.0.0.1:${server.port}/patbond-media/a-1',
|
||||||
|
headers: const {'Content-Type': 'image/jpeg'},
|
||||||
|
bytes: Uint8List.fromList(List.filled(1 << 20, 7)),
|
||||||
|
),
|
||||||
|
throwsA(
|
||||||
|
isA<MediaDirectUploadException>()
|
||||||
|
.having((e) => e.statusCode, 'statusCode', isNull)
|
||||||
|
.having(
|
||||||
|
(e) => e.isCredentialRejected,
|
||||||
|
'isCredentialRejected',
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/post_analytics.dart';
|
||||||
|
|
||||||
|
import '../../helpers/community_test_helpers.dart';
|
||||||
|
import '../../helpers/media_test_helpers.dart';
|
||||||
|
|
||||||
|
/// MediaUploader 状态机全路径(T3-13):
|
||||||
|
/// 选图→压缩→createUpload→直传→confirm→ready 编排、并发顺序保持、
|
||||||
|
/// 弱网失败语义(凭据过期重取 / 403 换凭据 / 42205 重试)与孤儿防护。
|
||||||
|
void main() {
|
||||||
|
late FakeCommunityRepository repository;
|
||||||
|
late FakeMediaImagePicker picker;
|
||||||
|
late FakeMediaCompressor compressor;
|
||||||
|
late FakeDirectUploadClient direct;
|
||||||
|
int uploadSeq = 0;
|
||||||
|
|
||||||
|
MediaUploader build({
|
||||||
|
int maxConcurrentUploads = 2,
|
||||||
|
int maxByteSize = 10 * 1024 * 1024,
|
||||||
|
DateTime Function()? now,
|
||||||
|
PostAnalytics? analytics,
|
||||||
|
}) {
|
||||||
|
return MediaUploader(
|
||||||
|
repository: repository,
|
||||||
|
picker: picker,
|
||||||
|
compressor: compressor,
|
||||||
|
directUpload: direct,
|
||||||
|
analytics: analytics,
|
||||||
|
maxConcurrentUploads: maxConcurrentUploads,
|
||||||
|
maxByteSize: maxByteSize,
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakeCommunityRepository();
|
||||||
|
picker = FakeMediaImagePicker([]);
|
||||||
|
compressor = FakeMediaCompressor();
|
||||||
|
direct = FakeDirectUploadClient();
|
||||||
|
uploadSeq = 0;
|
||||||
|
// 默认脚本:凭据按序发号,confirm 返回同 id 的 ready asset。
|
||||||
|
repository.onCreateMediaUpload = (request) async =>
|
||||||
|
credentials(assetId: 'a-${++uploadSeq}');
|
||||||
|
repository.onCompleteMediaUpload = (assetId) async =>
|
||||||
|
readyAsset(assetId: assetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('happy path', () {
|
||||||
|
test('单图全程:阶段序列 + 进度 + ready assetId 交付', () async {
|
||||||
|
direct.manual = true;
|
||||||
|
final uploader = build();
|
||||||
|
final phases = <MediaItemPhase>[];
|
||||||
|
uploader.addListener(() {
|
||||||
|
if (uploader.items.isNotEmpty) phases.add(uploader.items.single.phase);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
final call = direct.calls.single;
|
||||||
|
expect(uploader.items.single.phase, MediaItemPhase.uploading);
|
||||||
|
call.emitProgress(512, 1024);
|
||||||
|
expect(uploader.items.single.progress, 0.5);
|
||||||
|
// 上传中 assetId 不可见(孤儿防护)。
|
||||||
|
expect(uploader.items.single.assetId, isNull);
|
||||||
|
|
||||||
|
call.succeed();
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
final item = uploader.items.single;
|
||||||
|
expect(item.phase, MediaItemPhase.ready);
|
||||||
|
expect(item.assetId, 'a-1');
|
||||||
|
expect(item.progress, 1);
|
||||||
|
expect(uploader.allReady, isTrue);
|
||||||
|
expect(phases.first, MediaItemPhase.queued);
|
||||||
|
expect(
|
||||||
|
phases,
|
||||||
|
containsAllInOrder([
|
||||||
|
MediaItemPhase.queued,
|
||||||
|
MediaItemPhase.compressing,
|
||||||
|
MediaItemPhase.uploading,
|
||||||
|
MediaItemPhase.confirming,
|
||||||
|
MediaItemPhase.ready,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(repository.calls, ['createUpload:image/jpeg:64', 'complete:a-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('直传按凭据原样携带 requiredHeaders 与压缩产物字节', () async {
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage(seed: 7, size: 128)]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
final call = direct.calls.single;
|
||||||
|
expect(call.headers, {'Content-Type': 'image/jpeg'});
|
||||||
|
expect(call.url, contains('X-Amz-Signature'));
|
||||||
|
expect(call.bytes.length, 128);
|
||||||
|
// 登记 byteSize 与直传本体一致。
|
||||||
|
expect(repository.calls.first, 'createUpload:image/jpeg:128');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('压缩降质阶梯:80 超限降 60,登记与直传用 60 档产物', () async {
|
||||||
|
compressor.sizePerQuality = {80: 11 * 1024 * 1024, 60: 9 * 1024 * 1024};
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(compressor.qualities, [80, 60]);
|
||||||
|
expect(uploader.items.single.phase, MediaItemPhase.ready);
|
||||||
|
expect(direct.calls.single.bytes.length, 9 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('多图并发与顺序保持', () {
|
||||||
|
test('并发上限 2:第三张等槽位;完成乱序不影响 position 语义', () async {
|
||||||
|
direct.manual = true;
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([
|
||||||
|
pickedImage(seed: 1),
|
||||||
|
pickedImage(seed: 2),
|
||||||
|
pickedImage(seed: 3),
|
||||||
|
]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
// 只有前两张进入直传,第三张排队等槽位。
|
||||||
|
expect(direct.calls, hasLength(2));
|
||||||
|
expect(uploader.items[2].phase, MediaItemPhase.queued);
|
||||||
|
|
||||||
|
// 第二张先完成(乱序),释放槽位后第三张才发起。
|
||||||
|
direct.calls[1].succeed();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(direct.calls, hasLength(3));
|
||||||
|
direct.calls[0].succeed();
|
||||||
|
direct.calls[2].succeed();
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(uploader.allReady, isTrue);
|
||||||
|
final attach = uploader.buildAttachRequests();
|
||||||
|
// position 按加入序 0..2,与完成先后无关;isCover 恰好首图。
|
||||||
|
expect(attach.map((a) => a.position), [0, 1, 2]);
|
||||||
|
expect(attach.map((a) => a.isCover), [true, false, false]);
|
||||||
|
// 完成序:第 2 张先确认(a-2),仍归位 index 1。
|
||||||
|
expect(attach[1].assetId, 'a-2');
|
||||||
|
expect(attach[0].assetId, 'a-1');
|
||||||
|
expect(attach[2].assetId, 'a-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('单图失败不拖垮整批:另一张照常 ready', () async {
|
||||||
|
direct.manual = true;
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage(seed: 1), pickedImage(seed: 2)]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
direct.calls[0].fail(const MediaDirectUploadException(message: '断连'));
|
||||||
|
direct.calls[1].succeed();
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(uploader.items[0].phase, MediaItemPhase.failed);
|
||||||
|
expect(uploader.items[0].retryable, isTrue);
|
||||||
|
expect(uploader.items[1].phase, MediaItemPhase.ready);
|
||||||
|
expect(uploader.hasFailure, isTrue);
|
||||||
|
expect(uploader.allReady, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('弱网 / 失败语义', () {
|
||||||
|
test('直传网络中断 → failed 可重试;retry 复用压缩产物、换新 asset', () async {
|
||||||
|
direct.scriptedOutcomes.add(
|
||||||
|
const MediaDirectUploadException(message: '断连'),
|
||||||
|
);
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(uploader.items.single.phase, MediaItemPhase.failed);
|
||||||
|
expect(uploader.items.single.retryable, isTrue);
|
||||||
|
|
||||||
|
uploader.retry(uploader.items.single.localId);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
final item = uploader.items.single;
|
||||||
|
expect(item.phase, MediaItemPhase.ready);
|
||||||
|
// 重试从 createUpload 全新开始(a-2),旧 a-1 弃引用。
|
||||||
|
expect(item.assetId, 'a-2');
|
||||||
|
// 压缩只做一次(产物缓存)。
|
||||||
|
expect(compressor.qualities, [80]);
|
||||||
|
expect(repository.calls, [
|
||||||
|
'createUpload:image/jpeg:64',
|
||||||
|
'createUpload:image/jpeg:64',
|
||||||
|
'complete:a-2',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('凭据过期预检:PUT 前重新 createUpload 换新凭据', () async {
|
||||||
|
final clock = DateTime.utc(2026, 9, 9, 12);
|
||||||
|
var issued = 0;
|
||||||
|
repository.onCreateMediaUpload = (request) async {
|
||||||
|
issued++;
|
||||||
|
return credentials(
|
||||||
|
assetId: 'a-$issued',
|
||||||
|
// 首张凭据已进入 30 秒安全边距,第二张充足。
|
||||||
|
expiresAt: issued == 1
|
||||||
|
? clock.add(const Duration(seconds: 10))
|
||||||
|
: clock.add(const Duration(minutes: 10)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
final uploader = build(now: () => clock);
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(issued, 2);
|
||||||
|
expect(direct.calls.single.url, contains('/a-2?'));
|
||||||
|
expect(uploader.items.single.assetId, 'a-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('换新凭据仍过期 → failed 可重试,不再无限重取', () async {
|
||||||
|
final clock = DateTime.utc(2026, 9, 9, 12);
|
||||||
|
repository.onCreateMediaUpload = (request) async =>
|
||||||
|
credentials(expiresAt: clock);
|
||||||
|
final uploader = build(now: () => clock);
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(uploader.items.single.phase, MediaItemPhase.failed);
|
||||||
|
expect(uploader.items.single.retryable, isTrue);
|
||||||
|
expect(direct.calls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('存储侧 403(签名过期)→ 自动换新凭据重传一次后 ready', () async {
|
||||||
|
direct.scriptedOutcomes.add(
|
||||||
|
const MediaDirectUploadException(statusCode: 403, message: '签名过期'),
|
||||||
|
);
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(direct.calls, hasLength(2));
|
||||||
|
expect(uploader.items.single.phase, MediaItemPhase.ready);
|
||||||
|
expect(uploader.items.single.assetId, 'a-2');
|
||||||
|
expect(repository.calls.last, 'complete:a-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('换新凭据后仍 403 → failed 可重试,不无限自动重传', () async {
|
||||||
|
direct.scriptedOutcomes.addAll([
|
||||||
|
const MediaDirectUploadException(statusCode: 403, message: '签名过期'),
|
||||||
|
const MediaDirectUploadException(statusCode: 403, message: '签名过期'),
|
||||||
|
]);
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(direct.calls, hasLength(2));
|
||||||
|
expect(uploader.items.single.phase, MediaItemPhase.failed);
|
||||||
|
expect(uploader.items.single.retryable, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirm 42205(对象未上传/内容不符)→ failed 可重试,重试换新 asset', () async {
|
||||||
|
var confirms = 0;
|
||||||
|
repository.onCompleteMediaUpload = (assetId) async {
|
||||||
|
if (++confirms == 1) {
|
||||||
|
throw const MediaUploadStateException(message: '上传未完成');
|
||||||
|
}
|
||||||
|
return readyAsset(assetId: assetId);
|
||||||
|
};
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(uploader.items.single.phase, MediaItemPhase.failed);
|
||||||
|
expect(uploader.items.single.retryable, isTrue);
|
||||||
|
expect(uploader.items.single.assetId, isNull);
|
||||||
|
|
||||||
|
uploader.retry(uploader.items.single.localId);
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(uploader.items.single.phase, MediaItemPhase.ready);
|
||||||
|
expect(uploader.items.single.assetId, 'a-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirm 返回非 ready 状态(防御)→ failed 可重试', () async {
|
||||||
|
repository.onCompleteMediaUpload = (assetId) async =>
|
||||||
|
MediaAsset.fromJson({
|
||||||
|
...readyAssetJson(assetId: assetId),
|
||||||
|
'status': 'uploading',
|
||||||
|
'url': null,
|
||||||
|
'readyAt': null,
|
||||||
|
});
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(uploader.items.single.phase, MediaItemPhase.failed);
|
||||||
|
expect(uploader.items.single.retryable, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createUpload 40000(参数拒绝)→ failed 终态不可重试', () async {
|
||||||
|
repository.onCreateMediaUpload = (request) async {
|
||||||
|
throw const ApiBusinessException(code: 40000, message: 'mime 不允许');
|
||||||
|
};
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
final item = uploader.items.single;
|
||||||
|
expect(item.phase, MediaItemPhase.failed);
|
||||||
|
expect(item.retryable, isFalse);
|
||||||
|
uploader.retry(item.localId);
|
||||||
|
await pumpEventQueue();
|
||||||
|
// 终态失败 retry 为 no-op。
|
||||||
|
expect(repository.calls, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('压缩阶梯到底仍超限 → failed 终态,未发起任何网络调用', () async {
|
||||||
|
compressor.sizePerQuality = {80: 12 * 1024 * 1024, 60: 11 * 1024 * 1024};
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
final item = uploader.items.single;
|
||||||
|
expect(item.phase, MediaItemPhase.failed);
|
||||||
|
expect(item.retryable, isFalse);
|
||||||
|
expect(repository.calls, isEmpty);
|
||||||
|
expect(direct.calls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('压缩异常 → failed 可重试', () async {
|
||||||
|
compressor.error = Exception('原生编解码失败');
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(uploader.items.single.phase, MediaItemPhase.failed);
|
||||||
|
expect(uploader.items.single.retryable, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('孤儿防护(未 confirm 的 asset 不得被引用)', () {
|
||||||
|
test('非全员 ready 时 buildAttachRequests 抛 StateError', () async {
|
||||||
|
direct.manual = true;
|
||||||
|
final uploader = build();
|
||||||
|
expect(uploader.buildAttachRequests, throwsStateError);
|
||||||
|
|
||||||
|
uploader.addImages([pickedImage(seed: 1), pickedImage(seed: 2)]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
// 在途(uploading)。
|
||||||
|
expect(uploader.buildAttachRequests, throwsStateError);
|
||||||
|
|
||||||
|
direct.calls[0].succeed();
|
||||||
|
direct.calls[1].fail(const MediaDirectUploadException(message: '断连'));
|
||||||
|
await pumpEventQueue();
|
||||||
|
// 一 ready 一 failed:失败项在场仍禁止引用。
|
||||||
|
expect(uploader.readyCount, 1);
|
||||||
|
expect(uploader.buildAttachRequests, throwsStateError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('全生命周期快照 assetId 仅 ready 态非空', () async {
|
||||||
|
direct.manual = true;
|
||||||
|
final uploader = build();
|
||||||
|
final observed = <MediaItemPhase, String?>{};
|
||||||
|
uploader.addListener(() {
|
||||||
|
for (final item in uploader.items) {
|
||||||
|
observed[item.phase] = item.assetId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
direct.calls.single.succeed();
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(observed[MediaItemPhase.ready], 'a-1');
|
||||||
|
for (final MapEntry(:key, :value) in observed.entries) {
|
||||||
|
if (key != MediaItemPhase.ready) expect(value, isNull);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('直传在途 remove → 结果作废,不发 confirm', () async {
|
||||||
|
direct.manual = true;
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
uploader.remove(uploader.items.single.localId);
|
||||||
|
expect(uploader.isEmpty, isTrue);
|
||||||
|
|
||||||
|
direct.calls.single.succeed();
|
||||||
|
await pumpEventQueue();
|
||||||
|
// 已移除的图完成直传也不确认(asset 留给服务端超时清理)。
|
||||||
|
expect(repository.calls.where((c) => c.startsWith('complete')), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset 后在途 confirm 结果作废', () async {
|
||||||
|
direct.manual = true;
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
uploader.reset();
|
||||||
|
direct.calls.single.succeed();
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(uploader.isEmpty, isTrue);
|
||||||
|
expect(repository.calls.where((c) => c.startsWith('complete')), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('选图与容量', () {
|
||||||
|
test('pickAndAdd:picking 态、limit=剩余槽位、产物入列', () async {
|
||||||
|
picker.results.addAll([pickedImage(seed: 1), pickedImage(seed: 2)]);
|
||||||
|
picker.gate = Completer<void>();
|
||||||
|
final uploader = build();
|
||||||
|
|
||||||
|
final picking = uploader.pickAndAdd();
|
||||||
|
expect(uploader.isPicking, isTrue);
|
||||||
|
picker.gate!.complete();
|
||||||
|
await picking;
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(uploader.isPicking, isFalse);
|
||||||
|
expect(picker.limits, [9]);
|
||||||
|
expect(uploader.items, hasLength(2));
|
||||||
|
expect(uploader.allReady, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('用户取消选择(空结果)→ 恢复空闲,无副作用', () async {
|
||||||
|
final uploader = build();
|
||||||
|
await uploader.pickAndAdd();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(uploader.isPicking, isFalse);
|
||||||
|
expect(uploader.isEmpty, isTrue);
|
||||||
|
expect(repository.calls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('满 9 张后 pickAndAdd no-op;addImages 超量截断', () async {
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages(List.generate(11, (i) => pickedImage(seed: i)));
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(uploader.items, hasLength(9));
|
||||||
|
expect(uploader.remainingSlots, 0);
|
||||||
|
await uploader.pickAndAdd();
|
||||||
|
expect(picker.limits, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overallProgress 汇总:ready 计 1、uploading 计进度', () async {
|
||||||
|
direct.manual = true;
|
||||||
|
final uploader = build();
|
||||||
|
uploader.addImages([pickedImage(seed: 1), pickedImage(seed: 2)]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
direct.calls[0].succeed();
|
||||||
|
await pumpEventQueue();
|
||||||
|
direct.calls[1].emitProgress(1, 2);
|
||||||
|
expect(uploader.overallProgress, closeTo(0.75, 0.001));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 媒体上传三段埋点(T3-17;22 号白名单 v3 事件 27~29)----
|
||||||
|
group('媒体三段埋点', () {
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late PostAnalytics analytics;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
events = [];
|
||||||
|
analytics = PostAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Map<String, dynamic>?> named(String name) =>
|
||||||
|
events.where((event) => event.$1 == name).map((e) => e.$2).toList();
|
||||||
|
|
||||||
|
test('成功一图:started → succeeded 各一条,sizeBucket 同值 + durationMs', () async {
|
||||||
|
var clock = DateTime.utc(2026, 9, 10, 8);
|
||||||
|
// 凭据有效期以注入时钟为基准(避免与真实时钟错位触发过期重取)。
|
||||||
|
repository.onCreateMediaUpload = (_) async => credentials(
|
||||||
|
assetId: 'a-1',
|
||||||
|
expiresAt: clock.add(const Duration(minutes: 10)),
|
||||||
|
);
|
||||||
|
final uploader = build(analytics: analytics, now: () => clock);
|
||||||
|
|
||||||
|
direct.manual = true;
|
||||||
|
uploader.addImages([pickedImage(size: 2048)]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
// started 已记时;上传耗时 1200ms 后 confirm 成功。
|
||||||
|
clock = clock.add(const Duration(milliseconds: 1200));
|
||||||
|
direct.calls.single.succeed();
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(events.map((e) => e.$1), [
|
||||||
|
'post_media_upload_started',
|
||||||
|
'post_media_upload_succeeded',
|
||||||
|
]);
|
||||||
|
expect(named('post_media_upload_started').single, {
|
||||||
|
'mediaType': 'image',
|
||||||
|
'sizeBucket': 'lt_1mb',
|
||||||
|
});
|
||||||
|
expect(named('post_media_upload_succeeded').single, {
|
||||||
|
'mediaType': 'image',
|
||||||
|
'sizeBucket': 'lt_1mb',
|
||||||
|
'durationMs': 1200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('压缩后仍超限:started + failed(media_too_large, attemptSeq 1)', () async {
|
||||||
|
compressor.sizePerQuality = {80: 40, 60: 30};
|
||||||
|
final uploader = build(maxByteSize: 20, analytics: analytics);
|
||||||
|
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(uploader.items.single.retryable, isFalse);
|
||||||
|
expect(named('post_media_upload_succeeded'), isEmpty);
|
||||||
|
expect(named('post_media_upload_failed').single, {
|
||||||
|
'mediaType': 'image',
|
||||||
|
'sizeBucket': 'lt_1mb',
|
||||||
|
'failureReason': 'media_too_large',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('断连失败 → retry:failed(network_error) 后 attemptSeq 递增再成功', () async {
|
||||||
|
direct.scriptedOutcomes.add(
|
||||||
|
const MediaDirectUploadException(message: '断连'),
|
||||||
|
);
|
||||||
|
final uploader = build(analytics: analytics);
|
||||||
|
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(named('post_media_upload_failed').single, {
|
||||||
|
'mediaType': 'image',
|
||||||
|
'sizeBucket': 'lt_1mb',
|
||||||
|
'failureReason': 'network_error',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
uploader.retry(uploader.items.single.localId);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(uploader.items.single.phase, MediaItemPhase.ready);
|
||||||
|
expect(named('post_media_upload_started'), hasLength(2));
|
||||||
|
expect(named('post_media_upload_succeeded'), hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'createUpload 40000:failed(unsupported_format) 带 errorCode/httpStatus',
|
||||||
|
() async {
|
||||||
|
repository.onCreateMediaUpload = (_) async =>
|
||||||
|
throw const ApiBusinessException(code: 40000, message: 'bad mime');
|
||||||
|
final uploader = build(analytics: analytics);
|
||||||
|
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(named('post_media_upload_failed').single, {
|
||||||
|
'mediaType': 'image',
|
||||||
|
'sizeBucket': 'lt_1mb',
|
||||||
|
'failureReason': 'unsupported_format',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
'errorCode': 40000,
|
||||||
|
'httpStatus': 400,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('在途删格报 cancelled;ready 后删格不报', () async {
|
||||||
|
direct.manual = true;
|
||||||
|
final uploader = build(analytics: analytics);
|
||||||
|
uploader.addImages([pickedImage(seed: 1)]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
uploader.remove(uploader.items.single.localId);
|
||||||
|
expect(named('post_media_upload_failed').single, {
|
||||||
|
'mediaType': 'image',
|
||||||
|
'sizeBucket': 'lt_1mb',
|
||||||
|
'failureReason': 'cancelled',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
events.clear();
|
||||||
|
direct.manual = false;
|
||||||
|
uploader.addImages([pickedImage(seed: 2)]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(uploader.items.single.isReady, isTrue);
|
||||||
|
uploader.remove(uploader.items.single.localId);
|
||||||
|
expect(named('post_media_upload_failed'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('会话失效不上报失败(应用即将回登录页)', () async {
|
||||||
|
repository.onCreateMediaUpload = (_) async =>
|
||||||
|
throw const SessionExpiredException();
|
||||||
|
final uploader = build(analytics: analytics);
|
||||||
|
|
||||||
|
uploader.addImages([pickedImage()]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(uploader.items.single.isFailed, isTrue);
|
||||||
|
expect(named('post_media_upload_failed'), isEmpty);
|
||||||
|
expect(named('post_media_upload_started'), hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> readyAssetJson({required String assetId}) => {
|
||||||
|
'id': assetId,
|
||||||
|
'kind': 'image',
|
||||||
|
'purpose': 'post_image',
|
||||||
|
'mimeType': 'image/jpeg',
|
||||||
|
'byteSize': 1024,
|
||||||
|
'widthPx': 1080,
|
||||||
|
'heightPx': 810,
|
||||||
|
'status': 'ready',
|
||||||
|
'url': 'http://minio.local/p.jpg?X-Amz-Signature=sig',
|
||||||
|
'readyAt': '2026-09-09T00:00:00.000Z',
|
||||||
|
'createdAt': '2026-09-09T00:00:00.000Z',
|
||||||
|
};
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/post_analytics.dart';
|
||||||
|
|
||||||
|
/// post 域埋点封装(T3-17):键集与 22 号白名单 v3 事件 22~29 逐一对齐、
|
||||||
|
/// 分桶边界、异常 → failureReason 映射、隐私红线(无内容 ID / 无精确字数)。
|
||||||
|
void main() {
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late PostAnalytics analytics;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
events = [];
|
||||||
|
analytics = PostAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('键集与白名单对齐(22 号 §1)', () {
|
||||||
|
test('post_create_started:entryPoint', () {
|
||||||
|
analytics.postCreateStarted(entryPoint: PostEntryPoint.createTab);
|
||||||
|
expect(events.single.$1, 'post_create_started');
|
||||||
|
expect(events.single.$2, {'entryPoint': 'create_tab'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('post_draft_saved:trigger + mediaCount', () {
|
||||||
|
analytics.postDraftSaved(trigger: DraftSaveTrigger.onExit, mediaCount: 3);
|
||||||
|
expect(events.single.$1, 'post_draft_saved');
|
||||||
|
expect(events.single.$2, {'trigger': 'on_exit', 'mediaCount': 3});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('post_publish_succeeded:五键齐 + 正文只出分桶', () {
|
||||||
|
analytics.postPublishSucceeded(
|
||||||
|
durationMs: 8200,
|
||||||
|
mediaCount: 2,
|
||||||
|
topicCount: 0,
|
||||||
|
textLength: 120,
|
||||||
|
fromDraft: true,
|
||||||
|
);
|
||||||
|
final props = events.single.$2!;
|
||||||
|
expect(
|
||||||
|
props.keys,
|
||||||
|
containsAll(<String>[
|
||||||
|
'durationMs',
|
||||||
|
'mediaCount',
|
||||||
|
'topicCount',
|
||||||
|
'textLengthBucket',
|
||||||
|
'fromDraft',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(props['textLengthBucket'], 'medium');
|
||||||
|
expect(props['fromDraft'], isTrue);
|
||||||
|
// 红线 1/2:不带精确字数、不带 postId。
|
||||||
|
expect(props.containsKey('textLength'), isFalse);
|
||||||
|
expect(props.containsKey('postId'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'post_publish_failed:failureReason/attemptSeq/errorCode/httpStatus',
|
||||||
|
() {
|
||||||
|
analytics.postPublishFailed(
|
||||||
|
reason: PostPublishFailureReason.mediaUploadIncomplete,
|
||||||
|
attemptSeq: 2,
|
||||||
|
errorCode: 42203,
|
||||||
|
);
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'failureReason': 'media_upload_incomplete',
|
||||||
|
'attemptSeq': 2,
|
||||||
|
'errorCode': 42203,
|
||||||
|
'httpStatus': 422,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('post_publish_failed:网络错误无 errorCode/httpStatus', () {
|
||||||
|
analytics.postPublishFailed(
|
||||||
|
reason: PostPublishFailureReason.networkError,
|
||||||
|
attemptSeq: 1,
|
||||||
|
);
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'failureReason': 'network_error',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('post_deleted:空属性集(单事件风格)', () {
|
||||||
|
analytics.postDeleted();
|
||||||
|
expect(events.single.$1, 'post_deleted');
|
||||||
|
expect(events.single.$2, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('媒体三段:键集齐 + 只出桶不出字节数/文件名', () {
|
||||||
|
analytics.mediaUploadStarted(
|
||||||
|
mediaType: MediaType.image,
|
||||||
|
byteSize: 3 * 1024 * 1024,
|
||||||
|
);
|
||||||
|
analytics.mediaUploadSucceeded(
|
||||||
|
mediaType: MediaType.image,
|
||||||
|
byteSize: 3 * 1024 * 1024,
|
||||||
|
durationMs: 4300,
|
||||||
|
);
|
||||||
|
analytics.mediaUploadFailed(
|
||||||
|
mediaType: MediaType.image,
|
||||||
|
byteSize: 3 * 1024 * 1024,
|
||||||
|
reason: MediaUploadFailureReason.cancelled,
|
||||||
|
attemptSeq: 1,
|
||||||
|
);
|
||||||
|
expect(events.map((event) => event.$1), [
|
||||||
|
'post_media_upload_started',
|
||||||
|
'post_media_upload_succeeded',
|
||||||
|
'post_media_upload_failed',
|
||||||
|
]);
|
||||||
|
expect(events[0].$2, {'mediaType': 'image', 'sizeBucket': 'mb_1_5'});
|
||||||
|
expect(events[1].$2, {
|
||||||
|
'mediaType': 'image',
|
||||||
|
'sizeBucket': 'mb_1_5',
|
||||||
|
'durationMs': 4300,
|
||||||
|
});
|
||||||
|
expect(events[2].$2, {
|
||||||
|
'mediaType': 'image',
|
||||||
|
'sizeBucket': 'mb_1_5',
|
||||||
|
'failureReason': 'cancelled',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
});
|
||||||
|
for (final event in events) {
|
||||||
|
expect(event.$2!.containsKey('byteSize'), isFalse);
|
||||||
|
expect(event.$2!.containsKey('fileName'), isFalse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('分桶边界(06 §1.3 红线 4)', () {
|
||||||
|
test('mediaSizeBucketOf 四档', () {
|
||||||
|
const mib = 1024 * 1024;
|
||||||
|
expect(mediaSizeBucketOf(0), 'lt_1mb');
|
||||||
|
expect(mediaSizeBucketOf(mib - 1), 'lt_1mb');
|
||||||
|
expect(mediaSizeBucketOf(mib), 'mb_1_5');
|
||||||
|
expect(mediaSizeBucketOf(5 * mib - 1), 'mb_1_5');
|
||||||
|
expect(mediaSizeBucketOf(5 * mib), 'mb_5_20');
|
||||||
|
expect(mediaSizeBucketOf(20 * mib - 1), 'mb_5_20');
|
||||||
|
expect(mediaSizeBucketOf(20 * mib), 'gte_20mb');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('异常 → 发布失败原因', () {
|
||||||
|
test('42203 → media_upload_incomplete;40905/40000 → validation_error', () {
|
||||||
|
expect(
|
||||||
|
postPublishFailureReasonOf(const MediaNotReadyException(message: 'x')),
|
||||||
|
PostPublishFailureReason.mediaUploadIncomplete,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
postPublishFailureReasonOf(
|
||||||
|
const IdempotencyMismatchException(message: 'x'),
|
||||||
|
),
|
||||||
|
PostPublishFailureReason.validationError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
postPublishFailureReasonOf(
|
||||||
|
const ApiBusinessException(code: 40000, message: 'x'),
|
||||||
|
),
|
||||||
|
PostPublishFailureReason.validationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40403 → not_found;40902 → server_error;限流/网络各自归位', () {
|
||||||
|
expect(
|
||||||
|
postPublishFailureReasonOf(const PostNotFoundException(message: 'x')),
|
||||||
|
PostPublishFailureReason.notFound,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
postPublishFailureReasonOf(
|
||||||
|
const PostVersionConflictException(message: 'x'),
|
||||||
|
),
|
||||||
|
PostPublishFailureReason.serverError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
postPublishFailureReasonOf(const ApiRateLimitException()),
|
||||||
|
PostPublishFailureReason.rateLimited,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
postPublishFailureReasonOf(const ApiNetworkException()),
|
||||||
|
PostPublishFailureReason.networkError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('会话失效不上报(null)', () {
|
||||||
|
expect(
|
||||||
|
postPublishFailureReasonOf(const SessionExpiredException()),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.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 '../../helpers/community_test_helpers.dart';
|
||||||
|
import '../../helpers/media_test_helpers.dart';
|
||||||
|
|
||||||
|
/// 发布页(T3-17):gating、两条提交路径(直接发布 / 存草稿退出)、
|
||||||
|
/// 三条失败语义(40905 / 42203 / 网络可重试同键)、删格重排、草稿恢复与
|
||||||
|
/// 发布漏斗埋点断言。
|
||||||
|
void main() {
|
||||||
|
late FakeCommunityRepository repository;
|
||||||
|
late CommunityController controller;
|
||||||
|
late FakeMediaImagePicker picker;
|
||||||
|
late FakeMediaCompressor compressor;
|
||||||
|
late FakeDirectUploadClient direct;
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late PostAnalytics analytics;
|
||||||
|
late List<CreatePostRequest> created;
|
||||||
|
late List<UpdatePostRequest> updated;
|
||||||
|
bool? popResult;
|
||||||
|
int uploadSeq = 0;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakeCommunityRepository();
|
||||||
|
controller = CommunityController(repository: repository);
|
||||||
|
picker = FakeMediaImagePicker([
|
||||||
|
decodablePickedImage(seed: 1),
|
||||||
|
decodablePickedImage(seed: 2),
|
||||||
|
decodablePickedImage(seed: 3),
|
||||||
|
]);
|
||||||
|
compressor = FakeMediaCompressor();
|
||||||
|
direct = FakeDirectUploadClient();
|
||||||
|
events = [];
|
||||||
|
analytics = PostAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
created = [];
|
||||||
|
updated = [];
|
||||||
|
popResult = null;
|
||||||
|
uploadSeq = 0;
|
||||||
|
|
||||||
|
repository.onCreateMediaUpload = (_) async =>
|
||||||
|
credentials(assetId: 'a-${++uploadSeq}');
|
||||||
|
repository.onCompleteMediaUpload = (assetId) async =>
|
||||||
|
readyAsset(assetId: assetId);
|
||||||
|
repository.onCreatePost = (request, _) async {
|
||||||
|
created.add(request);
|
||||||
|
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
|
||||||
|
};
|
||||||
|
repository.onUpdatePost = (postId, request) async {
|
||||||
|
updated.add(request);
|
||||||
|
return Post.fromJson(
|
||||||
|
samplePostJson(id: postId, version: request.version + 1),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Map<String, dynamic>?> eventsNamed(String name) =>
|
||||||
|
events.where((event) => event.$1 == name).map((e) => e.$2).toList();
|
||||||
|
|
||||||
|
MediaUploader uploaderFactory(
|
||||||
|
CommunityRepository repo,
|
||||||
|
PostAnalytics? postAnalytics,
|
||||||
|
) => MediaUploader(
|
||||||
|
repository: repo,
|
||||||
|
picker: picker,
|
||||||
|
compressor: compressor,
|
||||||
|
directUpload: direct,
|
||||||
|
analytics: postAnalytics,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> pumpCompose(
|
||||||
|
WidgetTester tester, {
|
||||||
|
PostEntryPoint entryPoint = PostEntryPoint.createTab,
|
||||||
|
}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
popResult = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute<bool>(
|
||||||
|
builder: (_) => PostComposePage(
|
||||||
|
controller: controller,
|
||||||
|
entryPoint: entryPoint,
|
||||||
|
analytics: analytics,
|
||||||
|
uploaderFactory: uploaderFactory,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('打开发布页'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('打开发布页'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder publishButton() => find.widgetWithText(FilledButton, '发布');
|
||||||
|
|
||||||
|
bool publishEnabled(WidgetTester tester) =>
|
||||||
|
tester.widget<FilledButton>(publishButton()).onPressed != null;
|
||||||
|
|
||||||
|
Future<void> writeContent(
|
||||||
|
WidgetTester tester, [
|
||||||
|
String text = '带豆豆去了公园',
|
||||||
|
]) async {
|
||||||
|
await tester.enterText(find.byType(TextField), text);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选 [count] 张图并等上传完成。
|
||||||
|
Future<void> pickImages(WidgetTester tester, int count) async {
|
||||||
|
picker.results
|
||||||
|
..clear()
|
||||||
|
..addAll(List.generate(count, (i) => decodablePickedImage(seed: i + 1)));
|
||||||
|
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
group('gating(05 §2.2/§2.3 + 后端 content 必填)', () {
|
||||||
|
testWidgets('空正文禁用;输入即启用;post_create_started 只报一次', (tester) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
expect(publishEnabled(tester), isFalse);
|
||||||
|
|
||||||
|
await writeContent(tester, '豆');
|
||||||
|
expect(publishEnabled(tester), isTrue);
|
||||||
|
await writeContent(tester, '豆豆');
|
||||||
|
expect(eventsNamed('post_create_started'), hasLength(1));
|
||||||
|
expect(eventsNamed('post_create_started').single, {
|
||||||
|
'entryPoint': 'create_tab',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('图片在途禁用发布,全部 ready 才放行(孤儿防护)', (tester) async {
|
||||||
|
direct.manual = true;
|
||||||
|
await pumpCompose(tester, entryPoint: PostEntryPoint.feed);
|
||||||
|
await writeContent(tester);
|
||||||
|
|
||||||
|
picker.results
|
||||||
|
..clear()
|
||||||
|
..addAll([decodablePickedImage()]);
|
||||||
|
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(publishEnabled(tester), isFalse);
|
||||||
|
// 页级汇总条在途可见。
|
||||||
|
expect(find.textContaining('正在上传'), findsOneWidget);
|
||||||
|
|
||||||
|
direct.calls.single.succeed();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(publishEnabled(tester), isTrue);
|
||||||
|
expect(find.textContaining('正在上传'), findsNothing);
|
||||||
|
// 选媒体也算首次输入(entryPoint 归因 feed)。
|
||||||
|
expect(eventsNamed('post_create_started').single, {'entryPoint': 'feed'});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('直接发布路径(建草稿 → PATCH 迁移)', () {
|
||||||
|
testWidgets('纯文字帖:createPost(draft) + PATCH publish → pop(true) + 漏斗事件', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester, '带豆豆去了公园');
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(created.single.status, PostStatus.draft);
|
||||||
|
expect(created.single.content, '带豆豆去了公园');
|
||||||
|
expect(created.single.category, PostCategory.general);
|
||||||
|
expect(created.single.media, isNull);
|
||||||
|
expect(updated.single.publish, isTrue);
|
||||||
|
expect(updated.single.version, 1);
|
||||||
|
// 建草稿刚落,media 与服务端一致 → PATCH 缺席不动。
|
||||||
|
expect(updated.single.media, isNull);
|
||||||
|
expect(popResult, isTrue);
|
||||||
|
|
||||||
|
final succeeded = eventsNamed('post_publish_succeeded').single!;
|
||||||
|
expect(succeeded['mediaCount'], 0);
|
||||||
|
expect(succeeded['topicCount'], 0);
|
||||||
|
expect(succeeded['textLengthBucket'], 'short');
|
||||||
|
expect(succeeded['fromDraft'], isFalse);
|
||||||
|
expect(eventsNamed('post_publish_failed'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('求助类目 + 两图:position 0..1、首图封面、mediaCount 2', (tester) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester);
|
||||||
|
await tester.tap(find.text('求助'));
|
||||||
|
await tester.pump();
|
||||||
|
await pickImages(tester, 2);
|
||||||
|
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(created.single.category, PostCategory.help);
|
||||||
|
final media = created.single.media!;
|
||||||
|
expect(media.map((item) => item.position), [0, 1]);
|
||||||
|
expect(media.map((item) => item.isCover), [true, false]);
|
||||||
|
expect(media.map((item) => item.assetId), ['a-1', 'a-2']);
|
||||||
|
expect(eventsNamed('post_publish_succeeded').single!['mediaCount'], 2);
|
||||||
|
// 媒体三段逐文件各一对。
|
||||||
|
expect(eventsNamed('post_media_upload_started'), hasLength(2));
|
||||||
|
expect(eventsNamed('post_media_upload_succeeded'), hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('删格重排:3 图删中间 → position 重发号 0..1,弃掉的 asset 不被引用', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester);
|
||||||
|
await pickImages(tester, 3);
|
||||||
|
expect(find.byType(Image), findsNWidgets(3));
|
||||||
|
|
||||||
|
// 第二格删除角标(角标按格序渲染)。
|
||||||
|
await tester.tap(find.byIcon(Icons.close).at(1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(Image), findsNWidgets(2));
|
||||||
|
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final media = created.single.media!;
|
||||||
|
expect(media.map((item) => item.position), [0, 1]);
|
||||||
|
expect(media.map((item) => item.assetId), ['a-1', 'a-3']);
|
||||||
|
expect(media.map((item) => item.isCover), [true, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('发布失败三语义', () {
|
||||||
|
testWidgets('网络失败:横幅可重试 + 同键重放(不重复建帖)', (tester) async {
|
||||||
|
var attempts = 0;
|
||||||
|
repository.onCreatePost = (request, _) async {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts == 1) throw const ApiNetworkException();
|
||||||
|
created.add(request);
|
||||||
|
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester);
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(InlineErrorBanner), findsOneWidget);
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
// 草稿还没落服务端 → 不谎称已保存。
|
||||||
|
expect(find.text('草稿已保存,可稍后继续发布'), findsNothing);
|
||||||
|
expect(eventsNamed('post_publish_failed').single, {
|
||||||
|
'failureReason': 'network_error',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
});
|
||||||
|
expect(popResult, isNull);
|
||||||
|
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 两次尝试同一幂等键(服务端命中首帖,不重复建帖)。
|
||||||
|
expect(repository.idempotencyKeys, hasLength(2));
|
||||||
|
expect(repository.idempotencyKeys[0], isNotNull);
|
||||||
|
expect(repository.idempotencyKeys[0], repository.idempotencyKeys[1]);
|
||||||
|
expect(popResult, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('迁移发布失败:明确「草稿已保存」,重试只补 PATCH', (tester) async {
|
||||||
|
var patches = 0;
|
||||||
|
repository.onUpdatePost = (postId, request) async {
|
||||||
|
patches += 1;
|
||||||
|
if (patches == 1) throw const ApiNetworkException();
|
||||||
|
updated.add(request);
|
||||||
|
return Post.fromJson(samplePostJson(id: postId));
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester);
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('草稿已保存,可稍后继续发布'), findsOneWidget);
|
||||||
|
expect(created, hasLength(1));
|
||||||
|
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 草稿不重建,只重发 PATCH。
|
||||||
|
expect(created, hasLength(1));
|
||||||
|
expect(patches, 2);
|
||||||
|
expect(popResult, isTrue);
|
||||||
|
expect(eventsNamed('post_publish_failed').single!['attemptSeq'], 1);
|
||||||
|
expect(eventsNamed('post_publish_succeeded'), hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40905 同键异 hash:提示已重置提交标识,再点即换新键', (tester) async {
|
||||||
|
var attempts = 0;
|
||||||
|
repository.onCreatePost = (request, _) async {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts == 1) {
|
||||||
|
throw const IdempotencyMismatchException(message: 'mismatch');
|
||||||
|
}
|
||||||
|
created.add(request);
|
||||||
|
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester);
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('提交内容与上次重试不一致,已重置提交标识,请再点一次「发布」'), findsOneWidget);
|
||||||
|
expect(eventsNamed('post_publish_failed').single, {
|
||||||
|
'failureReason': 'validation_error',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
'errorCode': 40905,
|
||||||
|
'httpStatus': 409,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.idempotencyKeys, hasLength(2));
|
||||||
|
expect(
|
||||||
|
repository.idempotencyKeys[0],
|
||||||
|
isNot(repository.idempotencyKeys[1]),
|
||||||
|
);
|
||||||
|
expect(popResult, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('42203 asset 未 ready:提示等图片就绪 + media_upload_incomplete', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
repository.onCreatePost = (_, _) async =>
|
||||||
|
throw const MediaNotReadyException(message: 'not ready');
|
||||||
|
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester);
|
||||||
|
await pickImages(tester, 1);
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('有图片还没上传完成,请等图片就绪后再发布'), findsOneWidget);
|
||||||
|
expect(eventsNamed('post_publish_failed').single, {
|
||||||
|
'failureReason': 'media_upload_incomplete',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
'errorCode': 42203,
|
||||||
|
'httpStatus': 422,
|
||||||
|
});
|
||||||
|
expect(popResult, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('存草稿退出路径', () {
|
||||||
|
testWidgets('「存草稿」按钮:createPost(draft) + post_draft_saved(manual)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester);
|
||||||
|
await pickImages(tester, 1);
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '存草稿'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(created.single.status, PostStatus.draft);
|
||||||
|
expect(updated, isEmpty);
|
||||||
|
expect(find.text('已保存草稿 ✓'), findsOneWidget);
|
||||||
|
expect(eventsNamed('post_draft_saved').single, {
|
||||||
|
'trigger': 'manual',
|
||||||
|
'mediaCount': 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('「取消」有内容 → 保留草稿:on_exit 保存后离页', (tester) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester);
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '取消'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('保留草稿?'), findsOneWidget);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '保留'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(created, hasLength(1));
|
||||||
|
expect(eventsNamed('post_draft_saved').single, {
|
||||||
|
'trigger': 'on_exit',
|
||||||
|
'mediaCount': 0,
|
||||||
|
});
|
||||||
|
expect(popResult, isFalse);
|
||||||
|
expect(find.byType(PostComposePage), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('「不保留」:已落服务端的草稿软删 + post_deleted', (tester) async {
|
||||||
|
final deleted = <String>[];
|
||||||
|
repository.onDeletePost = (postId) async => deleted.add(postId);
|
||||||
|
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester);
|
||||||
|
// 先显式存一次草稿,使服务端已有草稿。
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '存草稿'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '取消'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '不保留'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(deleted, ['p-new']);
|
||||||
|
expect(eventsNamed('post_deleted'), hasLength(1));
|
||||||
|
expect(popResult, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('「继续编辑」留在页内,不动服务端', (tester) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester);
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '取消'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '继续编辑'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(PostComposePage), findsOneWidget);
|
||||||
|
expect(created, isEmpty);
|
||||||
|
expect(
|
||||||
|
repository.calls.where((c) => c.startsWith('deletePost')),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('空表单「取消」直接离页,无弹窗无请求', (tester) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '取消'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('保留草稿?'), findsNothing);
|
||||||
|
expect(created, isEmpty);
|
||||||
|
expect(popResult, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('草稿恢复(最小实现:最新一条)', () {
|
||||||
|
setUp(() {
|
||||||
|
repository.onListMyPosts = (status) async =>
|
||||||
|
postPage([sampleDraft(version: 3, content: '上次没写完的草稿')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('进页恢复:提示条 + 正文预填 + 已有图片说明', (tester) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
|
||||||
|
expect(find.text('已恢复上次草稿'), findsOneWidget);
|
||||||
|
expect(find.text('上次没写完的草稿'), findsOneWidget);
|
||||||
|
expect(find.textContaining('草稿已含 1 张图片'), findsOneWidget);
|
||||||
|
// 恢复不是用户输入 → 不发 post_create_started。
|
||||||
|
expect(eventsNamed('post_create_started'), isEmpty);
|
||||||
|
expect(repository.calls, contains('listMyPosts:draft'));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('恢复后发布:不再建草稿,PATCH 带 version 与 fromDraft=true', (tester) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(created, isEmpty);
|
||||||
|
expect(updated.single.version, 3);
|
||||||
|
expect(updated.single.publish, isTrue);
|
||||||
|
// 未重新选图 → media 缺席不动(服务端既有图保留)。
|
||||||
|
expect(updated.single.media, isNull);
|
||||||
|
final succeeded = eventsNamed('post_publish_succeeded').single!;
|
||||||
|
expect(succeeded['fromDraft'], isTrue);
|
||||||
|
expect(succeeded['mediaCount'], 1);
|
||||||
|
expect(popResult, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('恢复后重新选图:PATCH 整组替换', (tester) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await pickImages(tester, 1);
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(updated.single.media!.map((item) => item.assetId), ['a-1']);
|
||||||
|
expect(eventsNamed('post_publish_succeeded').single!['mediaCount'], 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40902 乐观锁:自动取新 version 重提一次即成功', (tester) async {
|
||||||
|
var patches = 0;
|
||||||
|
repository.onUpdatePost = (postId, request) async {
|
||||||
|
patches += 1;
|
||||||
|
if (patches == 1) {
|
||||||
|
throw const PostVersionConflictException(message: 'stale');
|
||||||
|
}
|
||||||
|
updated.add(request);
|
||||||
|
return Post.fromJson(samplePostJson(id: postId));
|
||||||
|
};
|
||||||
|
repository.onGetPost = (postId) async => Post.fromJson(
|
||||||
|
samplePostJson(id: postId, status: 'draft', version: 9),
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(updated.single.version, 9);
|
||||||
|
expect(eventsNamed('post_publish_failed'), isEmpty);
|
||||||
|
expect(popResult, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('「清空」清掉恢复内容与提示条', (tester) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '清空'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('已恢复上次草稿'), findsNothing);
|
||||||
|
expect(find.text('上次没写完的草稿'), findsNothing);
|
||||||
|
expect(publishEnabled(tester), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('草稿恢复失败静默降级为新建(不打断发布)', (tester) async {
|
||||||
|
repository.onListMyPosts = (_) async => throw const ApiNetworkException();
|
||||||
|
|
||||||
|
await pumpCompose(tester);
|
||||||
|
expect(find.text('已恢复上次草稿'), findsNothing);
|
||||||
|
expect(find.byType(InlineErrorBanner), findsNothing);
|
||||||
|
|
||||||
|
await writeContent(tester);
|
||||||
|
await tester.tap(publishButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(created, hasLength(1));
|
||||||
|
expect(popResult, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('页面结构(05 §2.3)', () {
|
||||||
|
testWidgets('AppBar 三件套 + 媒体编辑格 + 类目二选 + 位置占位', (tester) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
|
||||||
|
expect(find.text('发布动态'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(TextButton, '取消'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(TextButton, '存草稿'), findsOneWidget);
|
||||||
|
expect(publishButton(), findsOneWidget);
|
||||||
|
expect(find.byType(PostMediaEditGrid), findsOneWidget);
|
||||||
|
expect(find.text('日常分享'), findsOneWidget);
|
||||||
|
expect(find.text('求助'), findsOneWidget);
|
||||||
|
// ai_creation 是 M4 预留读侧值,不给提交面。
|
||||||
|
expect(find.textContaining('AI'), findsNothing);
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -240));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('添加位置(选填)'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('位置功能即将上线'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('正文 1000 字上限计数器在位', (tester) async {
|
||||||
|
await pumpCompose(tester);
|
||||||
|
await writeContent(tester, '豆豆');
|
||||||
|
expect(find.text('2/1000'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/feed_skeleton.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_card.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_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/home/home_page.dart';
|
||||||
|
import 'package:patbond_flutter/state/app_state.dart';
|
||||||
|
|
||||||
|
import '../../helpers/community_test_helpers.dart';
|
||||||
|
|
||||||
|
/// 短卡(纯文字)便于视口内多卡曝光断言;内容带 id 便于查重。
|
||||||
|
FeedCard textCard(String id) => FeedCard.fromJson({
|
||||||
|
...sampleFeedCardJson(id: id),
|
||||||
|
'coverImage': null,
|
||||||
|
'mediaCount': 0,
|
||||||
|
'title': null,
|
||||||
|
'contentPreview': '动态内容 $id',
|
||||||
|
});
|
||||||
|
|
||||||
|
FeedCard degradedCard(String id) => FeedCard.fromJson({
|
||||||
|
...sampleFeedCardJson(id: id),
|
||||||
|
'coverImage': null,
|
||||||
|
'mediaCount': 0,
|
||||||
|
'title': null,
|
||||||
|
'contentPreview': '动态内容 $id',
|
||||||
|
'author': sampleAuthorJson(nickname: null, avatarUrl: null),
|
||||||
|
});
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeCommunityRepository repository;
|
||||||
|
late CommunityController controller;
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late FeedAnalytics analytics;
|
||||||
|
late int composeTaps;
|
||||||
|
late List<String> openedPosts;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakeCommunityRepository();
|
||||||
|
events = [];
|
||||||
|
controller = CommunityController(
|
||||||
|
repository: repository,
|
||||||
|
interactionAnalytics: CommunityInteractionAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
analytics = FeedAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
composeTaps = 0;
|
||||||
|
openedPosts = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Map<String, dynamic>?> eventsNamed(String name) =>
|
||||||
|
events.where((e) => e.$1 == name).map((e) => e.$2).toList();
|
||||||
|
|
||||||
|
Future<void> pumpHome(WidgetTester tester, {bool isActive = true}) {
|
||||||
|
return tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: HomePage(
|
||||||
|
appState: AppState(),
|
||||||
|
communityController: controller,
|
||||||
|
feedAnalytics: analytics,
|
||||||
|
isActive: isActive,
|
||||||
|
onOpenServices: (_) {},
|
||||||
|
onOpenCompose: () => composeTaps++,
|
||||||
|
onOpenPost: openedPosts.add,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面纵向主列表(story 环是嵌套的水平 ListView,取 first 即外层)。
|
||||||
|
Finder list() => find.byType(ListView).first;
|
||||||
|
|
||||||
|
testWidgets('四态 · loading:首载渲染 3 张骨架屏', (tester) async {
|
||||||
|
final completer = Completer<CursorPage<FeedCard>>();
|
||||||
|
repository.onFeed = (_, _) => completer.future;
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// 首载骨架连排 3 张(列表懒加载,视口内至少 1 张被实例化)。
|
||||||
|
expect(find.byType(FeedSkeleton), findsAtLeastNWidgets(1));
|
||||||
|
|
||||||
|
completer.complete(feedPage(const []));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(FeedSkeleton), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('四态 · empty:空态插画 + 「发布第一条」CTA 进发布页', (tester) async {
|
||||||
|
repository.onFeed = (_, _) async => feedPage(const []);
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('还没有动态'), findsOneWidget);
|
||||||
|
await tester.drag(list(), const Offset(0, -300));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('发布第一条'));
|
||||||
|
expect(composeTaps, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('四态 · error:横幅 + 重试恢复 ready,feed_load_failed 上报', (tester) async {
|
||||||
|
var attempts = 0;
|
||||||
|
repository.onFeed = (_, _) async {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts == 1) throw const ApiNetworkException();
|
||||||
|
return feedPage([textCard('p-1')]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(InlineErrorBanner), findsOneWidget);
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
expect(eventsNamed('feed_load_failed'), [
|
||||||
|
{
|
||||||
|
'feedTab': 'home',
|
||||||
|
'loadType': 'refresh',
|
||||||
|
'failureReason': 'network_error',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await tester.drag(list(), const Offset(0, -200));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('重试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(InlineErrorBanner), findsNothing);
|
||||||
|
expect(find.text('动态内容 p-1'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('四态 · ready:卡片列表 + 降级作者「宠友」占位', (tester) async {
|
||||||
|
repository.onFeed = (_, _) async =>
|
||||||
|
feedPage([textCard('p-1'), degradedCard('p-2')]);
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(list(), const Offset(0, -400));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(PostCard), findsNWidgets(2));
|
||||||
|
expect(find.text('毛毛的铲屎官'), findsOneWidget);
|
||||||
|
expect(find.text('宠友'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('翻页:触底携游标加载下一页,不丢不重,到底显「没有更多了」', (tester) async {
|
||||||
|
final page2 = Completer<CursorPage<FeedCard>>();
|
||||||
|
repository.onFeed = (_, cursor) async {
|
||||||
|
if (cursor == null) {
|
||||||
|
return feedPage(
|
||||||
|
[textCard('p-1'), textCard('p-2')],
|
||||||
|
nextCursor: 'c1',
|
||||||
|
hasMore: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return page2.future;
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 触底:尾部 loading 转圈。
|
||||||
|
await tester.drag(list(), const Offset(0, -1000));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
|
||||||
|
page2.complete(feedPage([textCard('p-3')]));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(list(), const Offset(0, -600));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// keyset 游标不丢不重:三帖各恰一张,游标原样带出。
|
||||||
|
expect(repository.calls.where((c) => c.startsWith('feed:')).toList(), [
|
||||||
|
'feed:cursor=null',
|
||||||
|
'feed:cursor=c1',
|
||||||
|
]);
|
||||||
|
expect(find.text('动态内容 p-1'), findsOneWidget);
|
||||||
|
expect(find.text('动态内容 p-2'), findsOneWidget);
|
||||||
|
expect(find.text('动态内容 p-3'), findsOneWidget);
|
||||||
|
expect(find.text('没有更多了'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('尾部失败:重试条 + feed_load_failed(load_more),点按重试补页', (tester) async {
|
||||||
|
var page2Attempts = 0;
|
||||||
|
repository.onFeed = (_, cursor) async {
|
||||||
|
if (cursor == null) {
|
||||||
|
return feedPage([textCard('p-1')], nextCursor: 'c1', hasMore: true);
|
||||||
|
}
|
||||||
|
page2Attempts += 1;
|
||||||
|
if (page2Attempts == 1) {
|
||||||
|
throw const ApiBusinessException(code: 40000, message: 'x');
|
||||||
|
}
|
||||||
|
return feedPage([textCard('p-2')]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(list(), const Offset(0, -1000));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('加载失败,点此重试'), findsOneWidget);
|
||||||
|
expect(eventsNamed('feed_load_failed'), [
|
||||||
|
{
|
||||||
|
'feedTab': 'home',
|
||||||
|
'loadType': 'load_more',
|
||||||
|
'failureReason': 'server_error',
|
||||||
|
'errorCode': 40000,
|
||||||
|
'httpStatus': 400,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await tester.tap(find.text('加载失败,点此重试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(list(), const Offset(0, -400));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('动态内容 p-2'), findsOneWidget);
|
||||||
|
expect(find.text('加载失败,点此重试'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('下拉刷新:整体替换列表;刷新失败保留旧列表 + SnackBar', (tester) async {
|
||||||
|
var attempts = 0;
|
||||||
|
repository.onFeed = (_, _) async {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts == 1) return feedPage([textCard('p-1')]);
|
||||||
|
if (attempts == 2) throw const ApiNetworkException();
|
||||||
|
return feedPage([textCard('p-9')]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('动态内容 p-1'), findsOneWidget);
|
||||||
|
|
||||||
|
// 第一次下拉:失败——旧列表保留、SnackBar 轻提示、事件上报。
|
||||||
|
await tester.fling(list(), const Offset(0, 400), 1000);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('动态内容 p-1'), findsOneWidget);
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
expect(eventsNamed('feed_load_failed').single?['loadType'], 'refresh');
|
||||||
|
|
||||||
|
// 第二次下拉:成功——整体替换不残留旧卡。
|
||||||
|
await tester.fling(list(), const Offset(0, 400), 1000);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('动态内容 p-9'), findsOneWidget);
|
||||||
|
expect(find.text('动态内容 p-1'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('曝光结算 · 切走 Tab:可见 ≥500ms 的卡片计入,一段恰一条 feed_viewed', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
repository.onFeed = (_, _) async =>
|
||||||
|
feedPage([textCard('p-1'), textCard('p-2')]);
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(list(), const Offset(0, -1000));
|
||||||
|
// 先渲染一帧(滚动后位置在帧末扫描),再驻留满 500ms(曝光判定),
|
||||||
|
// 随后切走 Tab 结算。
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
|
||||||
|
expect(eventsNamed('feed_viewed'), isEmpty);
|
||||||
|
await pumpHome(tester, isActive: false);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final viewed = eventsNamed('feed_viewed');
|
||||||
|
expect(viewed, hasLength(1));
|
||||||
|
expect(viewed.single?['feedTab'], 'home');
|
||||||
|
expect(viewed.single?['impressionCount'], 2);
|
||||||
|
// 首屏自动预取不计刷新,本段无翻页。
|
||||||
|
expect(viewed.single?['refreshCount'], 0);
|
||||||
|
expect(viewed.single?['loadMoreCount'], 0);
|
||||||
|
// durationMs 取真实时钟(生产语义),widget 测试的 pump 只推进假
|
||||||
|
// 时钟,此处仅验证字段在场非负(精确口径见 feed_exposure_test)。
|
||||||
|
expect(viewed.single?['durationMs'], greaterThanOrEqualTo(0));
|
||||||
|
|
||||||
|
// 切回 Tab 开新段:再切走仍恰一条新事件(幂等不翻倍)。
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pump();
|
||||||
|
await pumpHome(tester, isActive: false);
|
||||||
|
await tester.pump();
|
||||||
|
expect(eventsNamed('feed_viewed'), hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('曝光结算 · 快速滑过不计;刷新/翻页计数入段', (tester) async {
|
||||||
|
final cards = List.generate(6, (i) => textCard('p-$i'));
|
||||||
|
repository.onFeed = (_, cursor) async {
|
||||||
|
if (cursor == null) {
|
||||||
|
return feedPage(cards, nextCursor: 'c1', hasMore: true);
|
||||||
|
}
|
||||||
|
return feedPage([textCard('p-6')]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 快速滑到底(各卡驻留 <500ms)→ 触发一次翻页。
|
||||||
|
await tester.drag(list(), const Offset(0, -2000));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 用户下拉刷新一次。
|
||||||
|
await tester.drag(list(), const Offset(0, 2000));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.fling(list(), const Offset(0, 400), 1000);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await pumpHome(tester, isActive: false);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final viewed = eventsNamed('feed_viewed').single!;
|
||||||
|
expect(viewed['refreshCount'], 1);
|
||||||
|
expect(viewed['loadMoreCount'], 1);
|
||||||
|
// 快速滑过的中段卡片未驻留满 500ms,不应全量计曝光。
|
||||||
|
expect(viewed['impressionCount'], lessThan(cards.length));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('曝光结算 · 退后台:结算一条,回前台开新段', (tester) async {
|
||||||
|
repository.onFeed = (_, _) async => feedPage([textCard('p-1')]);
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||||
|
await tester.pump();
|
||||||
|
// 级联 inactive→hidden→paused 只结算一次。
|
||||||
|
expect(eventsNamed('feed_viewed'), hasLength(1));
|
||||||
|
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
await tester.pump();
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||||
|
await tester.pump();
|
||||||
|
expect(eventsNamed('feed_viewed'), hasLength(2));
|
||||||
|
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('曝光结算 · 切到服务分段:结算;切回开新段', (tester) async {
|
||||||
|
repository.onFeed = (_, _) async => feedPage([textCard('p-1')]);
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('本地服务'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(eventsNamed('feed_viewed'), hasLength(1));
|
||||||
|
|
||||||
|
await tester.tap(find.text('社区动态'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('本地服务'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(eventsNamed('feed_viewed'), hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('T3-15 导航接通:整卡与评论钮点按回调 onOpenPost(占位提示移除)', (tester) async {
|
||||||
|
repository.onFeed = (_, _) async => feedPage([textCard('p-1')]);
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(list(), const Offset(0, -600));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('动态内容 p-1'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('帖子详情正在接入真实数据,敬请期待'), findsNothing);
|
||||||
|
expect(openedPosts, ['p-1']);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.chat_bubble_outline));
|
||||||
|
await tester.pump();
|
||||||
|
expect(openedPosts, ['p-1', 'p-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('T3-16 互动接线:点赞乐观翻转即时显示,成功报 post_liked(source=feed)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
repository.onFeed = (_, _) async => feedPage([
|
||||||
|
FeedCard.fromJson({
|
||||||
|
...sampleFeedCardJson(likeCount: 6),
|
||||||
|
'coverImage': null,
|
||||||
|
'mediaCount': 0,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
final like = Completer<LikeState>();
|
||||||
|
repository.onLikeToggle = (_, _) => like.future;
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(list(), const Offset(0, -800));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.favorite_border));
|
||||||
|
await tester.pump();
|
||||||
|
// 乐观翻转:请求未回已 +1(同帧反馈)。
|
||||||
|
expect(find.text('7'), findsOneWidget);
|
||||||
|
expect(repository.calls, contains('like:p-1'));
|
||||||
|
|
||||||
|
like.complete(const LikeState(liked: true, likeCount: 7));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(eventsNamed('post_liked'), [
|
||||||
|
{'source': 'feed'},
|
||||||
|
]);
|
||||||
|
expect(find.text('7'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('T3-16 互动接线:点赞失败回滚 + SnackBar「操作失败,请重试」', (tester) async {
|
||||||
|
repository.onFeed = (_, _) async => feedPage([
|
||||||
|
FeedCard.fromJson({
|
||||||
|
...sampleFeedCardJson(likeCount: 6),
|
||||||
|
'coverImage': null,
|
||||||
|
'mediaCount': 0,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
final like = Completer<LikeState>();
|
||||||
|
repository.onLikeToggle = (_, _) => like.future;
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(list(), const Offset(0, -800));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.favorite_border));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('7'), findsOneWidget);
|
||||||
|
|
||||||
|
like.completeError(const ApiNetworkException());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 回滚成对恢复 + SnackBar;不报 post_liked。
|
||||||
|
expect(find.text('6'), findsOneWidget);
|
||||||
|
expect(find.text('操作失败,请重试'), findsOneWidget);
|
||||||
|
expect(eventsNamed('post_liked'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('搜索词过滤已加载卡片;无命中显搜索空态', (tester) async {
|
||||||
|
repository.onFeed = (_, _) async =>
|
||||||
|
feedPage([textCard('p-1'), textCard('p-2')]);
|
||||||
|
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'p-1');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(PostCard), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '不存在的词');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(PostCard), findsNothing);
|
||||||
|
expect(find.text('没有找到相关动态'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/app/app.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_card.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../../helpers/auth_test_helpers.dart';
|
||||||
|
import '../../helpers/community_test_helpers.dart';
|
||||||
|
import '../../helpers/media_test_helpers.dart';
|
||||||
|
import '../../helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
|
/// 主壳发布闭环(T3-17):创作 Tab 入口 → 发布页 → 发布成功 →
|
||||||
|
/// 回首页 Feed 并整体刷新 → 新帖置顶可见(M3 验收「发布后可见」的
|
||||||
|
/// 客户端侧断言;跨客户端可见性走 compose 真链路实测,见 26 号报告 §5)。
|
||||||
|
void main() {
|
||||||
|
testWidgets('创作 Tab「发布动态」→ 发布 → 回首页 Feed 刷新,新帖置顶', (tester) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final session = SessionManager(store: InMemoryTokenStore())
|
||||||
|
..markAuthenticated();
|
||||||
|
final repository = FakeCommunityRepository();
|
||||||
|
|
||||||
|
var published = false;
|
||||||
|
repository.onFeed = (_, _) async => feedPage(
|
||||||
|
published
|
||||||
|
? [
|
||||||
|
FeedCard.fromJson({
|
||||||
|
...sampleFeedCardJson(id: 'p-new'),
|
||||||
|
'contentPreview': '刚发布的动态',
|
||||||
|
}),
|
||||||
|
sampleFeedCard(id: 'p-old'),
|
||||||
|
]
|
||||||
|
: [sampleFeedCard(id: 'p-old')],
|
||||||
|
);
|
||||||
|
repository.onCreatePost = (request, _) async =>
|
||||||
|
Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
|
||||||
|
repository.onUpdatePost = (postId, request) async {
|
||||||
|
published = true;
|
||||||
|
return Post.fromJson(samplePostJson(id: postId, version: 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
App(
|
||||||
|
sessionManager: session,
|
||||||
|
authRepository: FakeAuthRepository(),
|
||||||
|
petsRepository: FakePetsRepository(),
|
||||||
|
communityRepository: repository,
|
||||||
|
mediaUploaderFactory: (repo, analytics) => MediaUploader(
|
||||||
|
repository: repo,
|
||||||
|
picker: FakeMediaImagePicker([decodablePickedImage()]),
|
||||||
|
compressor: FakeMediaCompressor(),
|
||||||
|
directUpload: FakeDirectUploadClient(),
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 创作 Tab → 发布页(entryPoint=create_tab)。
|
||||||
|
await tester.tap(find.text('创作'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('发布动态'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.widgetWithText(TextButton, '存草稿'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, '刚发布的动态');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '发布'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 回首页 Feed(发布页已出栈)+ 整体刷新 + 新帖置顶。
|
||||||
|
expect(find.widgetWithText(TextButton, '存草稿'), findsNothing);
|
||||||
|
expect(find.text('已发布,去首页看看吧 🐾'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
repository.calls.where((call) => call.startsWith('feed:')),
|
||||||
|
hasLength(2),
|
||||||
|
);
|
||||||
|
// 卡片区在首页家具(story 环 / 促销位)之下,滚到可见后断言首位。
|
||||||
|
await tester.drag(find.byType(ListView).first, const Offset(0, -320));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final cards = tester.widgetList<PostCard>(find.byType(PostCard)).toList();
|
||||||
|
expect(cards.first.card.id, 'p-new');
|
||||||
|
expect(find.text('刚发布的动态'), findsWidgets);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
|
import '../../helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakePetsRepository repository;
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late HealthRecordAnalytics analytics;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakePetsRepository();
|
||||||
|
events = [];
|
||||||
|
analytics = HealthRecordAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Map<String, dynamic>?> eventsOf(String name) => [
|
||||||
|
for (final e in events)
|
||||||
|
if (e.$1 == name) e.$2,
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> pumpForm(WidgetTester tester) async {
|
||||||
|
tester.view.physicalSize = const Size(700, 1600);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<CareReminder>(
|
||||||
|
builder: (_) => CareReminderFormPage(
|
||||||
|
repository: repository,
|
||||||
|
petId: 'p-1',
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('打开表单'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('打开表单'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fillValid(WidgetTester tester) async {
|
||||||
|
await tester.tap(find.text('用药'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '提醒内容(如:体内外驱虫)'),
|
||||||
|
'心丝虫预防药',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('到期日期'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('OK'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('started 去重:首次输入一次;四类类型可选', (tester) async {
|
||||||
|
await pumpForm(tester);
|
||||||
|
await fillValid(tester);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '提醒内容(如:体内外驱虫)'),
|
||||||
|
'改个名字',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final started = eventsOf('health_record_create_started');
|
||||||
|
expect(started.single, {
|
||||||
|
'recordType': 'reminder',
|
||||||
|
'entryPoint': 'record_list',
|
||||||
|
});
|
||||||
|
// 四类类型齐备。
|
||||||
|
for (final label in ['驱虫', '体检', '用药', '其他']) {
|
||||||
|
expect(find.text(label), findsOneWidget);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40300 横幅 / 网络 SnackBar 兜底 + 失败事件', (tester) async {
|
||||||
|
var attempt = 0;
|
||||||
|
repository.createCareReminderHandler = (petId, request) async {
|
||||||
|
attempt++;
|
||||||
|
if (attempt == 1) {
|
||||||
|
throw const PetAccessDeniedException(message: '无权限');
|
||||||
|
}
|
||||||
|
throw const ApiNetworkException('断网');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await fillValid(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('保存提醒'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('你没有权限为该宠物添加提醒'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('保存提醒'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
final failed = eventsOf('health_record_create_failed');
|
||||||
|
expect(failed.length, 2);
|
||||||
|
expect(failed[0], {
|
||||||
|
'recordType': 'reminder',
|
||||||
|
'failureReason': 'permission_denied',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
'errorCode': 40300,
|
||||||
|
'httpStatus': 403,
|
||||||
|
});
|
||||||
|
expect(failed[1]!['failureReason'], 'network_error');
|
||||||
|
expect(failed[1]!['attemptSeq'], 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/care_reminders_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
import '../../helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakePetsRepository repository;
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late HealthRecordAnalytics analytics;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakePetsRepository();
|
||||||
|
events = [];
|
||||||
|
analytics = HealthRecordAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Map<String, dynamic>?> eventsOf(String name) => [
|
||||||
|
for (final e in events)
|
||||||
|
if (e.$1 == name) e.$2,
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> pumpPage(WidgetTester tester, {bool canWrite = true}) async {
|
||||||
|
tester.view.physicalSize = const Size(700, 1600);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: const Scaffold(body: Text('详情基底')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||||||
|
unawaited(
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => CareRemindersPage(
|
||||||
|
repository: repository,
|
||||||
|
petId: 'p-1',
|
||||||
|
canWrite: canWrite,
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相对当前时刻的待办样本(逾期判定依赖真实 now)。
|
||||||
|
CareReminder pendingIn(
|
||||||
|
String id,
|
||||||
|
Duration offset, {
|
||||||
|
Map<String, Object?> overrides = const {},
|
||||||
|
}) => buildReminder(
|
||||||
|
id,
|
||||||
|
overrides: {
|
||||||
|
'dueAt': DateTime.now().add(offset).toUtc().toIso8601String(),
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('四态 · ready:类型/时间/状态齐备,逾期待办红标;viewed 一次', (tester) async {
|
||||||
|
repository.listCareRemindersHandler = (petId, status) async => [
|
||||||
|
pendingIn(
|
||||||
|
'r-1',
|
||||||
|
const Duration(days: -3),
|
||||||
|
overrides: {'title': '体内外驱虫', 'reminderType': 'deworming'},
|
||||||
|
),
|
||||||
|
pendingIn('r-2', const Duration(days: 5)),
|
||||||
|
buildReminder(
|
||||||
|
'r-3',
|
||||||
|
overrides: {
|
||||||
|
'title': '疫苗加强针',
|
||||||
|
'reminderType': 'medication',
|
||||||
|
'status': 'completed',
|
||||||
|
'completedAt': '2026-09-02T10:00:00+08:00',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('体内外驱虫'), findsOneWidget);
|
||||||
|
expect(find.text('年度体检'), findsOneWidget);
|
||||||
|
expect(find.text('疫苗加强针'), findsOneWidget);
|
||||||
|
// 逾期视觉标识(工单硬项):过期待办标「已逾期」,未到期标「待办」。
|
||||||
|
expect(find.widgetWithText(TagPill, '已逾期'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(TagPill, '待办'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(TagPill, '已完成'), findsOneWidget);
|
||||||
|
// 待办行有完成/忽略动作,终态行没有。
|
||||||
|
expect(find.text('标记完成'), findsNWidgets(2));
|
||||||
|
expect(find.text('忽略'), findsNWidgets(2));
|
||||||
|
|
||||||
|
final viewed = eventsOf('health_record_viewed');
|
||||||
|
expect(viewed.single, {'recordType': 'reminder', 'source': 'pet_detail'});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('四态 · loading / empty:空态插画 + CTA;过滤空态无 CTA', (tester) async {
|
||||||
|
final completer = Completer<List<CareReminder>>();
|
||||||
|
final captured = <CareReminderStatus?>[];
|
||||||
|
repository.listCareRemindersHandler = (petId, status) {
|
||||||
|
captured.add(status);
|
||||||
|
if (captured.length == 1) return completer.future;
|
||||||
|
return Future.value(const []);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
|
||||||
|
completer.complete(const []);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(EmptyStateIllustration), findsOneWidget);
|
||||||
|
expect(find.text('还没有照护提醒'), findsOneWidget);
|
||||||
|
expect(find.text('添加第一条'), findsOneWidget);
|
||||||
|
|
||||||
|
// 切「已完成」过滤:status 参数透传服务端;过滤空态不给 CTA。
|
||||||
|
await tester.tap(find.text('已完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(captured, [null, CareReminderStatus.completed]);
|
||||||
|
expect(find.text('暂无「已完成」提醒'), findsOneWidget);
|
||||||
|
expect(find.text('添加第一条'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('四态 · error/retry:横幅 + 重试恢复', (tester) async {
|
||||||
|
var calls = 0;
|
||||||
|
repository.listCareRemindersHandler = (petId, status) async {
|
||||||
|
calls++;
|
||||||
|
if (calls == 1) throw const ApiNetworkException('断网');
|
||||||
|
return [pendingIn('r-1', const Duration(days: 5))];
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('重试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('年度体检'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('创建闭环:CTA → 表单(record_form 路由名)→ 请求形状与事件 → 成功重拉', (tester) async {
|
||||||
|
var listCalls = 0;
|
||||||
|
repository.listCareRemindersHandler = (petId, status) async {
|
||||||
|
listCalls++;
|
||||||
|
return listCalls == 1
|
||||||
|
? const []
|
||||||
|
: [
|
||||||
|
pendingIn(
|
||||||
|
'r-new',
|
||||||
|
const Duration(days: 30),
|
||||||
|
overrides: {'title': '体内外驱虫'},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
CreateCareReminderRequest? captured;
|
||||||
|
repository.createCareReminderHandler = (petId, request) async {
|
||||||
|
captured = request;
|
||||||
|
return pendingIn(
|
||||||
|
'r-new',
|
||||||
|
const Duration(days: 30),
|
||||||
|
overrides: {'title': '体内外驱虫'},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('添加第一条'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(CareReminderFormPage), findsOneWidget);
|
||||||
|
final route = ModalRoute.of(
|
||||||
|
tester.element(find.byType(CareReminderFormPage)),
|
||||||
|
)!;
|
||||||
|
expect(route.settings.name, 'record_form');
|
||||||
|
|
||||||
|
// 类型必选 + 标题必填 + 到期日期必选:先空提交拦截。
|
||||||
|
await tester.tap(find.text('保存提醒'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('请选择提醒类型'), findsOneWidget);
|
||||||
|
expect(find.text('请输入提醒内容'), findsOneWidget);
|
||||||
|
expect(find.text('请选择到期日期'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('驱虫'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '提醒内容(如:体内外驱虫)'),
|
||||||
|
'体内外驱虫',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('到期日期'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('OK'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('保存提醒'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final json = captured!.toJson();
|
||||||
|
expect(json['reminderType'], 'deworming');
|
||||||
|
expect(json['title'], '体内外驱虫');
|
||||||
|
expect(json['dueAt'], endsWith('Z'));
|
||||||
|
expect(json.containsKey('status'), isFalse);
|
||||||
|
|
||||||
|
expect(find.byType(CareReminderFormPage), findsNothing);
|
||||||
|
expect(find.text('已添加提醒'), findsOneWidget);
|
||||||
|
expect(listCalls, 2);
|
||||||
|
|
||||||
|
// 创建漏斗事件(recordType=reminder)。
|
||||||
|
final started = eventsOf('health_record_create_started');
|
||||||
|
expect(started.single, {
|
||||||
|
'recordType': 'reminder',
|
||||||
|
'entryPoint': 'record_list',
|
||||||
|
});
|
||||||
|
final failed = eventsOf('health_record_create_failed');
|
||||||
|
expect(failed.single!['failureReason'], 'validation_error');
|
||||||
|
final succeeded = eventsOf('health_record_create_succeeded');
|
||||||
|
expect(succeeded.single!['recordType'], 'reminder');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('标记完成:completedAt 必带(UTC);忽略:禁带 completedAt;成功重拉', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var listCalls = 0;
|
||||||
|
repository.listCareRemindersHandler = (petId, status) async {
|
||||||
|
listCalls++;
|
||||||
|
return [
|
||||||
|
pendingIn('r-1', const Duration(days: 5)),
|
||||||
|
pendingIn(
|
||||||
|
'r-2',
|
||||||
|
const Duration(days: 9),
|
||||||
|
overrides: {'title': '体内外驱虫'},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
final captured = <(String, Map<String, Object?>)>[];
|
||||||
|
repository.updateCareReminderHandler = (reminderId, request) async {
|
||||||
|
captured.add((reminderId, request.toJson()));
|
||||||
|
return buildReminder(
|
||||||
|
reminderId,
|
||||||
|
overrides: {
|
||||||
|
'status': request.status.name,
|
||||||
|
'completedAt': request.completedAt?.toIso8601String(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 完成 r-1:对话框(默认今天)确认 → PATCH completed + completedAt。
|
||||||
|
await tester.tap(find.text('标记完成').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('确认完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(captured.length, 1);
|
||||||
|
expect(captured[0].$1, 'r-1');
|
||||||
|
expect(captured[0].$2['status'], 'completed');
|
||||||
|
expect(captured[0].$2['completedAt'], endsWith('Z'));
|
||||||
|
expect(find.text('已标记完成'), findsOneWidget);
|
||||||
|
expect(listCalls, 2);
|
||||||
|
|
||||||
|
// 忽略 r-2:确认对话框 → PATCH dismissed,completedAt 键缺席。
|
||||||
|
await tester.tap(find.text('忽略').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('忽略这条提醒?'), findsOneWidget);
|
||||||
|
// 对话框主按钮文案与动作同名,用 FilledButton 定位。
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '忽略'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(captured.length, 2);
|
||||||
|
expect(captured[1].$2, {'status': 'dismissed'});
|
||||||
|
expect(listCalls, 3);
|
||||||
|
// 提醒完成/忽略不埋事件(06 §7 缺口 3 既定取舍)。
|
||||||
|
expect(eventsOf('health_record_edit_succeeded'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('42202 规则兜底:提示 + 重拉', (tester) async {
|
||||||
|
var listCalls = 0;
|
||||||
|
repository.listCareRemindersHandler = (petId, status) async {
|
||||||
|
listCalls++;
|
||||||
|
return [pendingIn('r-1', const Duration(days: 5))];
|
||||||
|
};
|
||||||
|
repository.updateCareReminderHandler = (reminderId, request) async {
|
||||||
|
throw const CareReminderRuleException(message: '规则违反');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('标记完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('确认完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('提醒状态不满足流转规则,已刷新,请重试'), findsOneWidget);
|
||||||
|
expect(listCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40902 并发流转抢先:提示已被处理 + 重拉', (tester) async {
|
||||||
|
var listCalls = 0;
|
||||||
|
repository.listCareRemindersHandler = (petId, status) async {
|
||||||
|
listCalls++;
|
||||||
|
return [pendingIn('r-1', const Duration(days: 5))];
|
||||||
|
};
|
||||||
|
repository.updateCareReminderHandler = (reminderId, request) async {
|
||||||
|
throw const PetVersionConflictException(message: '数据已被修改');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('标记完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('确认完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('提醒已在其他设备被处理,已刷新'), findsOneWidget);
|
||||||
|
expect(listCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('viewer(canWrite=false):无添加入口、无完成/忽略动作、空态无 CTA', (tester) async {
|
||||||
|
repository.listCareRemindersHandler = (petId, status) async => [
|
||||||
|
pendingIn('r-1', const Duration(days: 5)),
|
||||||
|
];
|
||||||
|
|
||||||
|
await pumpPage(tester, canWrite: false);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.add), findsNothing);
|
||||||
|
expect(find.text('标记完成'), findsNothing);
|
||||||
|
expect(find.text('忽略'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_event_edit_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
|
import '../../helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakePetsRepository repository;
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late HealthRecordAnalytics analytics;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakePetsRepository();
|
||||||
|
events = [];
|
||||||
|
analytics = HealthRecordAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Map<String, dynamic>?> eventsOf(String name) => [
|
||||||
|
for (final e in events)
|
||||||
|
if (e.$1 == name) e.$2,
|
||||||
|
];
|
||||||
|
|
||||||
|
HealthEvent baseEvent({int version = 3}) =>
|
||||||
|
buildHealthEvent('e-1', overrides: {'notes': '医院复查', 'version': version});
|
||||||
|
|
||||||
|
Future<void> pumpEdit(WidgetTester tester, {HealthEvent? event}) async {
|
||||||
|
tester.view.physicalSize = const Size(700, 1600);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<HealthEvent>(
|
||||||
|
builder: (_) => HealthEventEditPage(
|
||||||
|
repository: repository,
|
||||||
|
event: event ?? baseEvent(),
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('打开编辑'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('打开编辑'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('预填与身份静态区:类型/发生时间不可编辑,金额以元回显', (tester) async {
|
||||||
|
await pumpEdit(tester);
|
||||||
|
|
||||||
|
// 身份区:类型文案 + 发生时间(无输入控件)。
|
||||||
|
expect(find.text('就医'), findsOneWidget);
|
||||||
|
// 预填:标题 / 金额(12850 分 → 128.50 元)/ 备注。
|
||||||
|
expect(find.text('皮肤检查'), findsOneWidget);
|
||||||
|
expect(find.text('128.50'), findsOneWidget);
|
||||||
|
expect(find.text('医院复查'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('差量提交:只发送改动字段 + version;成功回传并报 edit_succeeded(fieldCount)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
UpdateHealthEventRequest? captured;
|
||||||
|
repository.updateHealthEventHandler = (eventId, request) async {
|
||||||
|
captured = request;
|
||||||
|
return buildHealthEvent('e-1', overrides: {'version': 4});
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpEdit(tester);
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '金额(元)'), '99');
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 备注未改不出现;金额 99 元 → 9900 分。
|
||||||
|
expect(captured!.toJson(), {
|
||||||
|
'version': 3,
|
||||||
|
'title': '皮肤复查',
|
||||||
|
'amountCents': 9900,
|
||||||
|
});
|
||||||
|
final succeeded = eventsOf('health_record_edit_succeeded');
|
||||||
|
expect(succeeded.single, {'recordType': 'health_event', 'fieldCount': 2});
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('无变更不发 PATCH 直接返回;清空可选字段视为不变更(契约不支持清空回 null)', (tester) async {
|
||||||
|
var called = false;
|
||||||
|
repository.updateHealthEventHandler = (eventId, request) async {
|
||||||
|
called = true;
|
||||||
|
return baseEvent();
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpEdit(tester);
|
||||||
|
// 清空金额与备注:契约不支持清空回 null → 视为不变更。
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '金额(元)'), '');
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '备注'), '');
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(called, isFalse);
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
expect(eventsOf('health_record_edit_succeeded'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40902 冲突:明确提示 + 经时间线检索自动取新 version(保留输入),重提成功', (tester) async {
|
||||||
|
final submittedVersions = <int>[];
|
||||||
|
var conflictOnce = true;
|
||||||
|
repository.updateHealthEventHandler = (eventId, request) async {
|
||||||
|
submittedVersions.add(request.version);
|
||||||
|
if (conflictOnce) {
|
||||||
|
conflictOnce = false;
|
||||||
|
throw const PetVersionConflictException(message: '数据已被修改');
|
||||||
|
}
|
||||||
|
return buildHealthEvent('e-1', overrides: {'version': 8});
|
||||||
|
};
|
||||||
|
// 契约无按 id 读取端点:40902 后经时间线分页检索取回最新版本(version 7)。
|
||||||
|
final listCaptured = <String?>[];
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async {
|
||||||
|
listCaptured.add(cursor);
|
||||||
|
return CursorPage(
|
||||||
|
items: [buildHealthEvent('e-other'), baseEvent(version: 7)],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpEdit(tester);
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 明确提示 + 用户输入保留。
|
||||||
|
expect(find.text('记录已在其他设备被修改,已获取最新版本,请核对后重新保存'), findsOneWidget);
|
||||||
|
expect(find.text('皮肤复查'), findsOneWidget);
|
||||||
|
expect(listCaptured, [null]);
|
||||||
|
|
||||||
|
// conflict 失败事件(M2 验收「并发冲突明确」数据面)。
|
||||||
|
final failed = eventsOf('health_record_edit_failed');
|
||||||
|
expect(failed.single, {
|
||||||
|
'recordType': 'health_event',
|
||||||
|
'failureReason': 'conflict',
|
||||||
|
'errorCode': 40902,
|
||||||
|
'httpStatus': 409,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重提:自动用检索回的新 version。
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(submittedVersions, [3, 7]);
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40402 记录不存在:SnackBar + 返回;40300 横幅;网络 SnackBar', (tester) async {
|
||||||
|
var attempt = 0;
|
||||||
|
repository.updateHealthEventHandler = (eventId, request) async {
|
||||||
|
attempt++;
|
||||||
|
switch (attempt) {
|
||||||
|
case 1:
|
||||||
|
throw const PetAccessDeniedException(message: '无权限');
|
||||||
|
case 2:
|
||||||
|
throw const ApiNetworkException('断网');
|
||||||
|
default:
|
||||||
|
throw const PetRecordNotFoundException(message: '不存在');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpEdit(tester);
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
|
||||||
|
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('你没有权限修改该记录'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
// 经 SnackBar「重试」重提(顺带锁定重试动作接线)→ 40402。
|
||||||
|
await tester.tap(find.text('重试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('记录不存在或已被删除'), findsOneWidget);
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
|
||||||
|
final failed = eventsOf('health_record_edit_failed');
|
||||||
|
expect(failed.length, 3);
|
||||||
|
expect(failed[0]!['failureReason'], 'permission_denied');
|
||||||
|
expect(failed[1]!['failureReason'], 'network_error');
|
||||||
|
expect(failed[2]!['failureReason'], 'not_found');
|
||||||
|
expect(failed[2]!['errorCode'], 40402);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_event_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
|
import '../../helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakePetsRepository repository;
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late HealthRecordAnalytics analytics;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakePetsRepository();
|
||||||
|
events = [];
|
||||||
|
analytics = HealthRecordAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Map<String, dynamic>?> eventsOf(String name) => [
|
||||||
|
for (final e in events)
|
||||||
|
if (e.$1 == name) e.$2,
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> pumpForm(WidgetTester tester) async {
|
||||||
|
tester.view.physicalSize = const Size(700, 1700);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<HealthEvent>(
|
||||||
|
builder: (_) => HealthEventFormPage(
|
||||||
|
repository: repository,
|
||||||
|
petId: 'p-1',
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('打开表单'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('打开表单'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fillValid(WidgetTester tester, {String? amount}) async {
|
||||||
|
await tester.tap(find.text('就医'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '标题(如:皮肤检查)'),
|
||||||
|
'皮肤检查',
|
||||||
|
);
|
||||||
|
if (amount != null) {
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '金额(元,可选)'),
|
||||||
|
amount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('本地校验:类型/标题缺失与金额非法拦截,不发请求且报 validation_error', (tester) async {
|
||||||
|
var called = false;
|
||||||
|
repository.createHealthEventHandler = (petId, request) async {
|
||||||
|
called = true;
|
||||||
|
return buildHealthEvent('e-1');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '金额(元,可选)'),
|
||||||
|
'12.345',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('请选择事件类型'), findsOneWidget);
|
||||||
|
expect(find.text('请输入标题'), findsOneWidget);
|
||||||
|
expect(find.text('金额格式不正确,最多两位小数'), findsOneWidget);
|
||||||
|
expect(called, isFalse);
|
||||||
|
final failed = eventsOf('health_record_create_failed');
|
||||||
|
expect(failed.single!['recordType'], 'health_event');
|
||||||
|
expect(failed.single!['failureReason'], 'validation_error');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('成功请求形状:六类类型、UTC 时间戳、金额元→整数分、无金额时键缺席', (tester) async {
|
||||||
|
CreateHealthEventRequest? captured;
|
||||||
|
repository.createHealthEventHandler = (petId, request) async {
|
||||||
|
captured = request;
|
||||||
|
return buildHealthEvent('e-1');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await fillValid(tester, amount: '128.50');
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final json = captured!.toJson();
|
||||||
|
expect(json['eventType'], 'medical');
|
||||||
|
expect(json['title'], '皮肤检查');
|
||||||
|
// 金额以元录入 → 整数分传输(工单硬项)。
|
||||||
|
expect(json['amountCents'], 12850);
|
||||||
|
// 今日默认此刻,转 UTC 带 Z 上送。
|
||||||
|
expect(json['occurredAt'], endsWith('Z'));
|
||||||
|
expect(json.containsKey('notes'), isFalse);
|
||||||
|
|
||||||
|
final succeeded = eventsOf('health_record_create_succeeded');
|
||||||
|
expect(succeeded.single!['recordType'], 'health_event');
|
||||||
|
expect(succeeded.single!['durationMs'], isA<int>());
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('无金额提交:amountCents 键整体缺席(非 0、非 null)', (tester) async {
|
||||||
|
CreateHealthEventRequest? captured;
|
||||||
|
repository.createHealthEventHandler = (petId, request) async {
|
||||||
|
captured = request;
|
||||||
|
return buildHealthEvent('e-1');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await fillValid(tester);
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(captured!.toJson().containsKey('amountCents'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('started 去重:首次输入一次,后续输入不再上报', (tester) async {
|
||||||
|
await pumpForm(tester);
|
||||||
|
await fillValid(tester);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '备注(可选)'),
|
||||||
|
'换季护理',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final started = eventsOf('health_record_create_started');
|
||||||
|
expect(started.single, {
|
||||||
|
'recordType': 'health_event',
|
||||||
|
'entryPoint': 'record_list',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40300 横幅 / 40000 横幅 / 网络 SnackBar 三兜底 + 失败事件', (tester) async {
|
||||||
|
var attempt = 0;
|
||||||
|
repository.createHealthEventHandler = (petId, request) async {
|
||||||
|
attempt++;
|
||||||
|
switch (attempt) {
|
||||||
|
case 1:
|
||||||
|
throw const PetAccessDeniedException(message: '无权限');
|
||||||
|
case 2:
|
||||||
|
throw const ApiBusinessException(code: 40000, message: '参数错误');
|
||||||
|
default:
|
||||||
|
throw const ApiNetworkException('断网');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await fillValid(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('你没有权限为该宠物添加记录'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('请检查填写内容后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
final failed = eventsOf('health_record_create_failed');
|
||||||
|
expect(failed.length, 3);
|
||||||
|
expect(failed[0]!['failureReason'], 'permission_denied');
|
||||||
|
expect(failed[0]!['errorCode'], 40300);
|
||||||
|
expect(failed[1]!['failureReason'], 'validation_error');
|
||||||
|
expect(failed[1]!['errorCode'], 40000);
|
||||||
|
expect(failed[2]!['failureReason'], 'network_error');
|
||||||
|
expect(failed[2]!['attemptSeq'], 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_event_edit_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_event_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_events_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
import '../../helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakePetsRepository repository;
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late HealthRecordAnalytics analytics;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakePetsRepository();
|
||||||
|
events = [];
|
||||||
|
analytics = HealthRecordAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Map<String, dynamic>?> eventsOf(String name) => [
|
||||||
|
for (final e in events)
|
||||||
|
if (e.$1 == name) e.$2,
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> pumpPage(
|
||||||
|
WidgetTester tester, {
|
||||||
|
bool canWrite = true,
|
||||||
|
int? pageSize,
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = const Size(700, 1600);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: const Scaffold(body: Text('详情基底')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||||||
|
unawaited(
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => HealthEventsPage(
|
||||||
|
repository: repository,
|
||||||
|
petId: 'p-1',
|
||||||
|
canWrite: canWrite,
|
||||||
|
analytics: analytics,
|
||||||
|
pageSize: pageSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
CursorPage<HealthEvent> page(
|
||||||
|
List<HealthEvent> items, {
|
||||||
|
String? next,
|
||||||
|
bool hasMore = false,
|
||||||
|
}) => CursorPage(items: items, nextCursor: next, hasMore: hasMore);
|
||||||
|
|
||||||
|
testWidgets('四态 · ready:六类条目、月分组组头、金额元展示、类型标签;viewed 一次', (tester) async {
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async => page([
|
||||||
|
buildHealthEvent(
|
||||||
|
'e-1',
|
||||||
|
overrides: {'occurredAt': '2026-09-05T14:00:00+08:00'},
|
||||||
|
),
|
||||||
|
buildHealthEvent(
|
||||||
|
'e-2',
|
||||||
|
overrides: {
|
||||||
|
'eventType': 'deworming',
|
||||||
|
'title': '体内驱虫',
|
||||||
|
'occurredAt': '2026-09-01T10:00:00+08:00',
|
||||||
|
'amountCents': null,
|
||||||
|
'notes': '博来恩',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
buildHealthEvent(
|
||||||
|
'e-3',
|
||||||
|
overrides: {
|
||||||
|
'eventType': 'note',
|
||||||
|
'title': '换粮观察',
|
||||||
|
'occurredAt': '2026-08-20T10:00:00+08:00',
|
||||||
|
'amountCents': null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 月分组:9 月两条 + 8 月一条各一枚组头。
|
||||||
|
expect(find.text('2026 年 9 月'), findsOneWidget);
|
||||||
|
expect(find.text('2026 年 8 月'), findsOneWidget);
|
||||||
|
// 条目标题与副行(日期 · 备注)。
|
||||||
|
expect(find.text('皮肤检查'), findsOneWidget);
|
||||||
|
expect(find.textContaining('· 博来恩'), findsOneWidget);
|
||||||
|
// 类型 TagPill(图标 + 文字双通道)。
|
||||||
|
expect(find.widgetWithText(TagPill, '就医'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(TagPill, '驱虫'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(TagPill, '随手记'), findsOneWidget);
|
||||||
|
// 金额:12850 分 → 元展示;无金额条目不渲染金额。
|
||||||
|
expect(find.text('¥128.50'), findsOneWidget);
|
||||||
|
|
||||||
|
final viewed = eventsOf('health_record_viewed');
|
||||||
|
expect(viewed.single, {
|
||||||
|
'recordType': 'health_event',
|
||||||
|
'source': 'pet_detail',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('四态 · loading / empty:空态插画 + 录入 CTA', (tester) async {
|
||||||
|
final completer = Completer<CursorPage<HealthEvent>>();
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) =>
|
||||||
|
completer.future;
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
|
||||||
|
completer.complete(page(const []));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(EmptyStateIllustration), findsOneWidget);
|
||||||
|
expect(find.text('还没有健康记录'), findsOneWidget);
|
||||||
|
expect(find.text('记录第一条'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('四态 · error/retry:横幅 + 重试恢复', (tester) async {
|
||||||
|
var calls = 0;
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async {
|
||||||
|
calls++;
|
||||||
|
if (calls == 1) throw const ApiNetworkException('断网');
|
||||||
|
return page([buildHealthEvent('e-1')]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('重试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('皮肤检查'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('cursor 分页:透传游标追加不重不漏,末页收起按钮;翻页失败保留重试', (tester) async {
|
||||||
|
final captured = <(int?, String?)>[];
|
||||||
|
var moreFails = true;
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async {
|
||||||
|
captured.add((limit, cursor));
|
||||||
|
if (cursor == null) {
|
||||||
|
return page([buildHealthEvent('e-1')], next: 'CUR-1', hasMore: true);
|
||||||
|
}
|
||||||
|
if (moreFails) {
|
||||||
|
moreFails = false;
|
||||||
|
throw const ApiNetworkException('断网');
|
||||||
|
}
|
||||||
|
return page([
|
||||||
|
buildHealthEvent(
|
||||||
|
'e-2',
|
||||||
|
overrides: {
|
||||||
|
'title': '洗澡美容',
|
||||||
|
'eventType': 'grooming',
|
||||||
|
'occurredAt': '2026-09-01T10:00:00+08:00',
|
||||||
|
'amountCents': null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester, pageSize: 1);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('皮肤检查'), findsOneWidget);
|
||||||
|
expect(find.text('加载更多'), findsOneWidget);
|
||||||
|
|
||||||
|
// 第一次翻页失败:SnackBar + 按钮保留。
|
||||||
|
await tester.tap(find.text('加载更多'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
expect(find.text('加载更多'), findsOneWidget);
|
||||||
|
|
||||||
|
// 重试成功:追加且不重复,末页收起按钮。
|
||||||
|
await tester.tap(find.text('加载更多'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('皮肤检查'), findsOneWidget);
|
||||||
|
expect(find.text('洗澡美容'), findsOneWidget);
|
||||||
|
expect(find.text('加载更多'), findsNothing);
|
||||||
|
|
||||||
|
expect(captured, [(1, null), (1, 'CUR-1'), (1, 'CUR-1')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('录入闭环:CTA → 表单(record_form 路由名)→ 成功后重拉首页', (tester) async {
|
||||||
|
var listCalls = 0;
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async {
|
||||||
|
listCalls++;
|
||||||
|
return listCalls == 1
|
||||||
|
? page(const [])
|
||||||
|
: page([
|
||||||
|
buildHealthEvent('e-new', overrides: {'title': '首次体检'}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
repository.createHealthEventHandler = (petId, request) async =>
|
||||||
|
buildHealthEvent('e-new', overrides: {'title': '首次体检'});
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('记录第一条'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(HealthEventFormPage), findsOneWidget);
|
||||||
|
final route = ModalRoute.of(
|
||||||
|
tester.element(find.byType(HealthEventFormPage)),
|
||||||
|
)!;
|
||||||
|
expect(route.settings.name, 'record_form');
|
||||||
|
|
||||||
|
await tester.tap(find.text('就医'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '标题(如:皮肤检查)'),
|
||||||
|
'首次体检',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(HealthEventFormPage), findsNothing);
|
||||||
|
expect(find.text('已记录健康事件'), findsOneWidget);
|
||||||
|
// 排序/月分组以服务端为准:成功后重拉首页。
|
||||||
|
expect(listCalls, 2);
|
||||||
|
expect(find.text('首次体检'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('编辑闭环:点条目 → 编辑页(无路由名)→ 成功后就地替换', (tester) async {
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
|
||||||
|
page([buildHealthEvent('e-1')]);
|
||||||
|
repository.updateHealthEventHandler = (eventId, request) async =>
|
||||||
|
buildHealthEvent('e-1', overrides: {'title': '皮肤复查', 'version': 2});
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('皮肤检查'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(HealthEventEditPage), findsOneWidget);
|
||||||
|
final route = ModalRoute.of(
|
||||||
|
tester.element(find.byType(HealthEventEditPage)),
|
||||||
|
)!;
|
||||||
|
// 编辑页不带路由名:record_form 专属创建漏斗到达段。
|
||||||
|
expect(route.settings.name, isNull);
|
||||||
|
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
expect(find.text('已保存修改'), findsOneWidget);
|
||||||
|
expect(find.text('皮肤复查'), findsOneWidget);
|
||||||
|
expect(find.text('皮肤检查'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('viewer(canWrite=false):无录入入口、空态无 CTA、点条目不进编辑', (tester) async {
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
|
||||||
|
page([buildHealthEvent('e-1')]);
|
||||||
|
|
||||||
|
await pumpPage(tester, canWrite: false);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.add), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('皮肤检查'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -82,4 +82,45 @@ void main() {
|
|||||||
'source': 'pet_detail',
|
'source': 'pet_detail',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('editSucceeded:recordType + fieldCount(差量键数,不含 version)', () {
|
||||||
|
analytics.editSucceeded(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
fieldCount: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$1, 'health_record_edit_succeeded');
|
||||||
|
expect(events.single.$2, {'recordType': 'health_event', 'fieldCount': 2});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'editFailed:conflict(40902)带 errorCode 与 httpStatus 推导,无 attemptSeq',
|
||||||
|
() {
|
||||||
|
analytics.editFailed(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
reason: HealthRecordFailureReason.conflict,
|
||||||
|
errorCode: 40902,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$1, 'health_record_edit_failed');
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'recordType': 'vaccine',
|
||||||
|
'failureReason': 'conflict',
|
||||||
|
'errorCode': 40902,
|
||||||
|
'httpStatus': 409,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('editFailed:网络失败可空属性整体缺席', () {
|
||||||
|
analytics.editFailed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
reason: HealthRecordFailureReason.networkError,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'recordType': 'health_event',
|
||||||
|
'failureReason': 'network_error',
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
@@ -147,4 +148,133 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('T2-14 · 健康事件展示纯函数', () {
|
||||||
|
test('六类事件 → RecordType 映射齐备(note 归「其他」,其余五类专属)', () {
|
||||||
|
expect(
|
||||||
|
recordTypeForHealthEvent(HealthEventType.medical),
|
||||||
|
RecordType.medical,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordTypeForHealthEvent(HealthEventType.feeding),
|
||||||
|
RecordType.feeding,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordTypeForHealthEvent(HealthEventType.deworming),
|
||||||
|
RecordType.deworming,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordTypeForHealthEvent(HealthEventType.grooming),
|
||||||
|
RecordType.grooming,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordTypeForHealthEvent(HealthEventType.measurement),
|
||||||
|
RecordType.measurement,
|
||||||
|
);
|
||||||
|
expect(recordTypeForHealthEvent(HealthEventType.note), RecordType.other);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('六类事件中文文案', () {
|
||||||
|
expect(
|
||||||
|
[for (final t in HealthEventType.values) healthEventTypeLabel(t)],
|
||||||
|
['就医', '喂养', '驱虫', '洗护', '测量', '随手记'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('月分组组头:按本地时区归月', () {
|
||||||
|
expect(healthEventMonthHeader(DateTime(2026, 9, 5, 14)), '2026 年 9 月');
|
||||||
|
expect(healthEventMonthHeader(DateTime(2025, 12, 31)), '2025 年 12 月');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('T2-14 · tz 固定偏移(summary tz 参数)', () {
|
||||||
|
test('正/负/零/半小时偏移格式', () {
|
||||||
|
expect(tzOffsetQueryValue(const Duration(hours: 8)), '+08:00');
|
||||||
|
expect(
|
||||||
|
tzOffsetQueryValue(const Duration(hours: -5, minutes: -30)),
|
||||||
|
'-05:30',
|
||||||
|
);
|
||||||
|
expect(tzOffsetQueryValue(Duration.zero), '+00:00');
|
||||||
|
expect(
|
||||||
|
tzOffsetQueryValue(const Duration(hours: 5, minutes: 45)),
|
||||||
|
'+05:45',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('T2-14 · 照护提醒展示纯函数', () {
|
||||||
|
test('四类提醒文案与视觉映射(体检/用药归就医族)', () {
|
||||||
|
expect(
|
||||||
|
[for (final t in CareReminderType.values) careReminderTypeLabel(t)],
|
||||||
|
['驱虫', '体检', '用药', '其他'],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordTypeForReminder(CareReminderType.deworming),
|
||||||
|
RecordType.deworming,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordTypeForReminder(CareReminderType.checkup),
|
||||||
|
RecordType.medical,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordTypeForReminder(CareReminderType.medication),
|
||||||
|
RecordType.medical,
|
||||||
|
);
|
||||||
|
expect(recordTypeForReminder(CareReminderType.other), RecordType.other);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('逾期判定:仅待办且 dueAt 已过;标签与基色随之切换', () {
|
||||||
|
final now = DateTime(2026, 9, 8, 12);
|
||||||
|
final overduePending = buildReminder(
|
||||||
|
'r-1',
|
||||||
|
overrides: {'dueAt': '2026-09-01T00:00:00+08:00'},
|
||||||
|
);
|
||||||
|
final futurePending = buildReminder(
|
||||||
|
'r-2',
|
||||||
|
overrides: {'dueAt': '2026-10-01T00:00:00+08:00'},
|
||||||
|
);
|
||||||
|
// 已完成的过期提醒不算逾期(终态)。
|
||||||
|
final completedPast = buildReminder(
|
||||||
|
'r-3',
|
||||||
|
overrides: {
|
||||||
|
'dueAt': '2026-09-01T00:00:00+08:00',
|
||||||
|
'status': 'completed',
|
||||||
|
'completedAt': '2026-09-02T10:00:00+08:00',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isReminderOverdue(overduePending, now), isTrue);
|
||||||
|
expect(isReminderOverdue(futurePending, now), isFalse);
|
||||||
|
expect(isReminderOverdue(completedPast, now), isFalse);
|
||||||
|
|
||||||
|
expect(reminderStatusTag(overduePending, now), '已逾期');
|
||||||
|
expect(reminderStatusColor(overduePending, now), AppColors.error);
|
||||||
|
expect(reminderStatusTag(futurePending, now), '待办');
|
||||||
|
expect(reminderStatusColor(futurePending, now), AppColors.accent);
|
||||||
|
expect(reminderStatusTag(completedPast, now), '已完成');
|
||||||
|
expect(reminderStatusColor(completedPast, now), AppColors.success);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('时间副行按状态给语义(到期 / 完成于 / 已忽略)', () {
|
||||||
|
final now = DateTime(2026, 9, 8);
|
||||||
|
final pending = buildReminder('r-1');
|
||||||
|
final completed = buildReminder(
|
||||||
|
'r-2',
|
||||||
|
overrides: {
|
||||||
|
'status': 'completed',
|
||||||
|
'completedAt': '2026-09-02T10:00:00+08:00',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final dismissed = buildReminder(
|
||||||
|
'r-3',
|
||||||
|
overrides: {'status': 'dismissed'},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reminderDateLine(pending), startsWith('到期 '));
|
||||||
|
expect(reminderDateLine(completed), startsWith('完成于 '));
|
||||||
|
expect(reminderDateLine(dismissed), startsWith('已忽略 · 原到期 '));
|
||||||
|
expect(reminderStatusTag(dismissed, now), '已忽略');
|
||||||
|
expect(reminderStatusColor(dismissed, now), AppColors.muted);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/care_reminders_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_events_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
|
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
|
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
|
||||||
@@ -24,8 +27,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Future<void> pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async {
|
Future<void> pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async {
|
||||||
// 详情页自 T2-13 增加健康数据卡行,加高视口保证底部按钮在栏内。
|
// 详情页自 T2-13 增加健康数据卡行、T2-14 增加花费卡与记录导航区,
|
||||||
tester.view.physicalSize = const Size(700, 1800);
|
// 加高视口保证底部按钮在栏内。
|
||||||
|
tester.view.physicalSize = const Size(700, 2200);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -184,7 +188,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('T2-13 · 数据卡行接 summary', () {
|
group('T2-13 · 数据卡行接 summary', () {
|
||||||
testWidgets('三卡取数:最新体重 / 疫苗进度 / 下一针(含疫苗名)', (tester) async {
|
testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 本月花费(tz 透传)', (tester) async {
|
||||||
repository.getPetHandler = (petId) async => buildPet('p-1');
|
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||||
String? capturedTz;
|
String? capturedTz;
|
||||||
repository.getPetSummaryHandler = (petId, tz) async {
|
repository.getPetSummaryHandler = (petId, tz) async {
|
||||||
@@ -202,8 +206,12 @@ void main() {
|
|||||||
expect(find.text('疫苗进度'), findsOneWidget);
|
expect(find.text('疫苗进度'), findsOneWidget);
|
||||||
expect(find.text('2026-08-01'), findsOneWidget);
|
expect(find.text('2026-08-01'), findsOneWidget);
|
||||||
expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
|
expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
|
||||||
// 本单不消费 monthlyExpense(T2-14),tz 不传走服务端缺省 UTC。
|
// T2-14:月度花费卡接 monthlyExpense(12850 分 → 元展示)。
|
||||||
expect(capturedTz, isNull);
|
expect(find.text('¥128.50'), findsOneWidget);
|
||||||
|
expect(find.text('本月花费'), findsOneWidget);
|
||||||
|
// T2-13 遗留③:tz 透传设备时区固定偏移(月度窗口随设备时区)。
|
||||||
|
expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset));
|
||||||
|
expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$')));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async {
|
testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async {
|
||||||
@@ -292,4 +300,156 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.add), findsNothing);
|
expect(find.byIcon(Icons.add), findsNothing);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('T2-14 · 时间线入口', () {
|
||||||
|
testWidgets('点本月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async {
|
||||||
|
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||||
|
var summaryCalls = 0;
|
||||||
|
repository.getPetSummaryHandler = (petId, tz) async {
|
||||||
|
summaryCalls++;
|
||||||
|
return buildSummary();
|
||||||
|
};
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
|
||||||
|
const CursorPage(items: [], nextCursor: null, hasMore: false);
|
||||||
|
|
||||||
|
await pumpDetail(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('本月花费'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(HealthEventsPage), findsOneWidget);
|
||||||
|
expect(find.text('健康时间线'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pageBack();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(summaryCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('记录导航区「健康时间线」入口可达(viewer 透传隐藏录入)', (tester) async {
|
||||||
|
repository.getPetHandler = (petId) async =>
|
||||||
|
buildPet('p-1', overrides: {'myRole': 'viewer'});
|
||||||
|
repository.getPetSummaryHandler = (petId, tz) async => buildSummary();
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
|
||||||
|
const CursorPage(items: [], nextCursor: null, hasMore: false);
|
||||||
|
|
||||||
|
await pumpDetail(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final tile = find.widgetWithText(ListTile, '健康时间线');
|
||||||
|
expect(tile, findsOneWidget);
|
||||||
|
await tester.ensureVisible(tile);
|
||||||
|
await tester.tap(tile);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(HealthEventsPage), findsOneWidget);
|
||||||
|
// viewer:空态无 CTA、AppBar 无添加入口。
|
||||||
|
expect(find.text('记录第一条'), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.add), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('T2-14 · 健康提醒真实数据驱动(取代 demo 硬编码)', () {
|
||||||
|
testWidgets('有待办:alert 卡显示最近到期提醒;入口副行显示待办数;点卡进提醒页并回拉', (tester) async {
|
||||||
|
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||||
|
final due = DateTime.now().add(const Duration(days: 30));
|
||||||
|
var reminderCalls = 0;
|
||||||
|
final capturedStatus = <CareReminderStatus?>[];
|
||||||
|
repository.listCareRemindersHandler = (petId, status) async {
|
||||||
|
reminderCalls++;
|
||||||
|
capturedStatus.add(status);
|
||||||
|
return [
|
||||||
|
buildReminder(
|
||||||
|
'r-1',
|
||||||
|
overrides: {
|
||||||
|
'title': '已经半年没有进行体内外驱虫',
|
||||||
|
'reminderType': 'deworming',
|
||||||
|
'dueAt': due.toUtc().toIso8601String(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
buildReminder('r-2'),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpDetail(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// alert 卡取 due_at ASC 首条(最近到期)真实数据。
|
||||||
|
expect(
|
||||||
|
find.text('健康提醒:已经半年没有进行体内外驱虫(${dateToJson(due)} 到期)'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('2 条待办'), findsOneWidget);
|
||||||
|
// 详情页只拉待办视图。
|
||||||
|
expect(capturedStatus.first, CareReminderStatus.pending);
|
||||||
|
|
||||||
|
final alert = find.textContaining('健康提醒:');
|
||||||
|
await tester.ensureVisible(alert);
|
||||||
|
await tester.tap(alert);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(CareRemindersPage), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pageBack();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 返回后重拉待办(详情页 1 次 + 提醒页自身 1 次 + 返回重拉 1 次)。
|
||||||
|
expect(reminderCalls, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('逾期待办:alert 卡切警示形态', (tester) async {
|
||||||
|
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||||
|
repository.listCareRemindersHandler = (petId, status) async => [
|
||||||
|
buildReminder(
|
||||||
|
'r-1',
|
||||||
|
overrides: {
|
||||||
|
'dueAt': DateTime.now()
|
||||||
|
.subtract(const Duration(days: 7))
|
||||||
|
.toUtc()
|
||||||
|
.toIso8601String(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await pumpDetail(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('健康提醒:年度体检(已逾期)'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('无待办:不渲染 alert 卡(无 demo 占位),入口副行「暂无待办提醒」', (tester) async {
|
||||||
|
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||||
|
repository.listCareRemindersHandler = (petId, status) async => const [];
|
||||||
|
|
||||||
|
await pumpDetail(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('健康提醒:'), findsNothing);
|
||||||
|
expect(find.text('暂无待办提醒'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('待办加载失败:主链路不受阻,入口副行降级提示且仍可进提醒页', (tester) async {
|
||||||
|
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||||
|
var calls = 0;
|
||||||
|
repository.listCareRemindersHandler = (petId, status) async {
|
||||||
|
calls++;
|
||||||
|
if (calls == 1) throw const ApiNetworkException('断网');
|
||||||
|
return const [];
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpDetail(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('豆豆'), findsOneWidget);
|
||||||
|
expect(find.text('提醒加载失败,点击查看'), findsOneWidget);
|
||||||
|
|
||||||
|
final tile = find.widgetWithText(ListTile, '照护提醒');
|
||||||
|
await tester.ensureVisible(tile);
|
||||||
|
await tester.tap(tile);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(CareRemindersPage), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:patbond_flutter/core/network/api_exception.dart';
|
|||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
|
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
|
||||||
import 'package:patbond_flutter/features/pets/vaccination_records_page.dart';
|
import 'package:patbond_flutter/features/pets/vaccination_records_page.dart';
|
||||||
@@ -185,4 +186,173 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.add), findsNothing);
|
expect(find.byIcon(Icons.add), findsNothing);
|
||||||
expect(find.text('登记第一针'), findsNothing);
|
expect(find.text('登记第一针'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('T2-14 · 流转动作(25 号报告遗留①②)', () {
|
||||||
|
testWidgets('标记完成:厂商/批号补录 + 请求形状 + edit_succeeded;动作仅 scheduled 行', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var listCalls = 0;
|
||||||
|
repository.listVaccinationsHandler = (petId) async {
|
||||||
|
listCalls++;
|
||||||
|
return [
|
||||||
|
buildVaccination('vx-1'),
|
||||||
|
buildVaccination(
|
||||||
|
'vx-2',
|
||||||
|
overrides: {
|
||||||
|
'doseNo': 2,
|
||||||
|
'status': 'completed',
|
||||||
|
'plannedOn': null,
|
||||||
|
'administeredOn': '2026-06-12',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
final captured = <(String, Map<String, Object?>)>[];
|
||||||
|
repository.updateVaccinationHandler = (vaccinationId, request) async {
|
||||||
|
captured.add((vaccinationId, request.toJson()));
|
||||||
|
return buildVaccination(
|
||||||
|
'vx-1',
|
||||||
|
overrides: {'status': 'completed', 'version': 2},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 动作仅 scheduled 行渲染(completed/cancelled 终态无动作)。
|
||||||
|
expect(find.text('标记完成'), findsOneWidget);
|
||||||
|
expect(find.text('取消登记'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('标记完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 完成对话框:接种日期默认今天;补录厂商/批号(契约可选字段)。
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '厂商(可选)'),
|
||||||
|
'硕腾',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '批号(可选)'),
|
||||||
|
'LOT-2026-09',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('确认完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(captured.length, 1);
|
||||||
|
expect(captured[0].$1, 'vx-1');
|
||||||
|
final json = captured[0].$2;
|
||||||
|
expect(json['version'], 1);
|
||||||
|
expect(json['status'], 'completed');
|
||||||
|
expect(json['administeredOn'], isA<String>());
|
||||||
|
expect(json['manufacturer'], '硕腾');
|
||||||
|
expect(json['batchNo'], 'LOT-2026-09');
|
||||||
|
// 未选下次接种:键缺席。
|
||||||
|
expect(json.containsKey('nextDueOn'), isFalse);
|
||||||
|
|
||||||
|
expect(find.text('已标记完成'), findsOneWidget);
|
||||||
|
expect(listCalls, 2);
|
||||||
|
|
||||||
|
final succeeded = eventsOf('health_record_edit_succeeded');
|
||||||
|
expect(succeeded.single, {'recordType': 'vaccine', 'fieldCount': 4});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('取消登记:确认后仅发 version + status=cancelled', (tester) async {
|
||||||
|
var listCalls = 0;
|
||||||
|
repository.listVaccinationsHandler = (petId) async {
|
||||||
|
listCalls++;
|
||||||
|
return [buildVaccination('vx-1')];
|
||||||
|
};
|
||||||
|
final captured = <Map<String, Object?>>[];
|
||||||
|
repository.updateVaccinationHandler = (vaccinationId, request) async {
|
||||||
|
captured.add(request.toJson());
|
||||||
|
return buildVaccination(
|
||||||
|
'vx-1',
|
||||||
|
overrides: {'status': 'cancelled', 'version': 2},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('取消登记'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('取消这条登记?'), findsOneWidget);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '取消登记'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(captured.single, {'version': 1, 'status': 'cancelled'});
|
||||||
|
expect(find.text('已取消登记'), findsOneWidget);
|
||||||
|
expect(listCalls, 2);
|
||||||
|
|
||||||
|
final succeeded = eventsOf('health_record_edit_succeeded');
|
||||||
|
expect(succeeded.single, {'recordType': 'vaccine', 'fieldCount': 1});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40902 并发修改:提示 + 重拉 + edit_failed(conflict)', (tester) async {
|
||||||
|
var listCalls = 0;
|
||||||
|
repository.listVaccinationsHandler = (petId) async {
|
||||||
|
listCalls++;
|
||||||
|
return [buildVaccination('vx-1')];
|
||||||
|
};
|
||||||
|
repository.updateVaccinationHandler = (vaccinationId, request) async {
|
||||||
|
throw const PetVersionConflictException(message: '数据已被修改');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('标记完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('确认完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('记录已在其他设备被修改,已刷新,请重试'), findsOneWidget);
|
||||||
|
expect(listCalls, 2);
|
||||||
|
|
||||||
|
final failed = eventsOf('health_record_edit_failed');
|
||||||
|
expect(failed.single, {
|
||||||
|
'recordType': 'vaccine',
|
||||||
|
'failureReason': 'conflict',
|
||||||
|
'errorCode': 40902,
|
||||||
|
'httpStatus': 409,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('42201 规则兜底:提示核对重试 + edit_failed(validation_error)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
repository.listVaccinationsHandler = (petId) async => [
|
||||||
|
buildVaccination('vx-1'),
|
||||||
|
];
|
||||||
|
repository.updateVaccinationHandler = (vaccinationId, request) async {
|
||||||
|
throw const VaccinationRuleException(message: '规则违反');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('标记完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('确认完成'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('接种状态与日期不符合规则,请核对后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
final failed = eventsOf('health_record_edit_failed');
|
||||||
|
expect(failed.single!['failureReason'], 'validation_error');
|
||||||
|
expect(failed.single!['errorCode'], 42201);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('viewer:scheduled 行也无流转动作', (tester) async {
|
||||||
|
repository.listVaccinationsHandler = (petId) async => [
|
||||||
|
buildVaccination('vx-1'),
|
||||||
|
];
|
||||||
|
|
||||||
|
await pumpPage(tester, canWrite: false);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('标记完成'), findsNothing);
|
||||||
|
expect(find.text('取消登记'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/comment_tile.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/post/post_detail_page.dart';
|
||||||
|
|
||||||
|
import '../../helpers/community_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeCommunityRepository repository;
|
||||||
|
late CommunityController controller;
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late CommunityInteractionAnalytics analytics;
|
||||||
|
|
||||||
|
/// 无媒体帖(避免测试环境网络图噪音;媒体形态单测另立)。
|
||||||
|
Post textPost({
|
||||||
|
String id = 'p-1',
|
||||||
|
bool likedByMe = false,
|
||||||
|
int likeCount = 6,
|
||||||
|
int commentCount = 3,
|
||||||
|
}) => Post.fromJson({
|
||||||
|
...samplePostJson(id: id, likedByMe: likedByMe, likeCount: likeCount),
|
||||||
|
'media': <Object>[],
|
||||||
|
'commentCount': commentCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakeCommunityRepository();
|
||||||
|
events = [];
|
||||||
|
analytics = CommunityInteractionAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
controller = CommunityController(
|
||||||
|
repository: repository,
|
||||||
|
interactionAnalytics: analytics,
|
||||||
|
);
|
||||||
|
// 缺省行为:正常帖 + 空评论 + 未关注(各测试按需覆盖)。
|
||||||
|
repository.onGetPost = (_) async => textPost();
|
||||||
|
repository.onListComments = (_, _) async => commentPage(const []);
|
||||||
|
repository.onGetFollowStats = (_) async => const FollowStats(
|
||||||
|
followerCount: 1,
|
||||||
|
followingCount: 2,
|
||||||
|
followedByMe: false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Map<String, dynamic>?> eventsNamed(String name) =>
|
||||||
|
events.where((e) => e.$1 == name).map((e) => e.$2).toList();
|
||||||
|
|
||||||
|
Widget detailApp({String? currentUserId = 'me'}) => MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: PostDetailPage(
|
||||||
|
controller: controller,
|
||||||
|
postId: 'p-1',
|
||||||
|
currentUserId: currentUserId,
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 经启动页 push 详情(40403 返回 Feed 的 pop 断言用)。
|
||||||
|
Widget launcherApp({String? currentUserId = 'me'}) => MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => PostDetailPage(
|
||||||
|
controller: controller,
|
||||||
|
postId: 'p-1',
|
||||||
|
currentUserId: currentUserId,
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('打开详情'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
group('详情四态', () {
|
||||||
|
testWidgets('loading:请求未回渲染转圈', (tester) async {
|
||||||
|
final post = Completer<Post>();
|
||||||
|
repository.onGetPost = (_) => post.future;
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
|
||||||
|
post.complete(textPost());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('晒了一下午太阳。'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ready:正文/作者/求助标/评论标题齐全', (tester) async {
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('今天的豆豆'), findsOneWidget);
|
||||||
|
expect(find.text('晒了一下午太阳。'), findsOneWidget);
|
||||||
|
expect(find.text('毛毛的铲屎官'), findsOneWidget);
|
||||||
|
expect(find.text('评论 (3)'), findsOneWidget);
|
||||||
|
expect(find.text('还没有评论,来抢沙发'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('error:横幅 + 重试恢复 ready', (tester) async {
|
||||||
|
var attempts = 0;
|
||||||
|
repository.onGetPost = (_) async {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts == 1) throw const ApiNetworkException();
|
||||||
|
return textPost();
|
||||||
|
};
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(InlineErrorBanner), findsOneWidget);
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('重试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(InlineErrorBanner), findsNothing);
|
||||||
|
expect(find.text('晒了一下午太阳。'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40403 不存在态:SnackBar + 返回 Feed 并触发刷新', (tester) async {
|
||||||
|
repository.onGetPost = (_) async =>
|
||||||
|
throw const PostNotFoundException(message: 'gone');
|
||||||
|
repository.onFeed = (_, _) async => feedPage(const []);
|
||||||
|
|
||||||
|
await tester.pumpWidget(launcherApp());
|
||||||
|
await tester.tap(find.text('打开详情'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 已弹回启动页 + 提示 + Feed 整体刷新(失效帖剔除)。
|
||||||
|
expect(find.text('打开详情'), findsOneWidget);
|
||||||
|
expect(find.text('帖子不存在或已被删除'), findsOneWidget);
|
||||||
|
expect(repository.calls, contains('feed:cursor=null'));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('内存副本先渲染:进入即展示缓存,后台拉新不阻塞', (tester) async {
|
||||||
|
await controller.getPost('p-1'); // 预热详情副本。
|
||||||
|
final refresh = Completer<Post>();
|
||||||
|
repository.onGetPost = (_) => refresh.future;
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pump();
|
||||||
|
// 拉新未回已渲染缓存内容,无全页转圈。
|
||||||
|
expect(find.text('晒了一下午太阳。'), findsOneWidget);
|
||||||
|
|
||||||
|
refresh.complete(textPost(likeCount: 9));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('9'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('媒体', () {
|
||||||
|
testWidgets('多图渲染真九宫格,点格进全屏大图(页码 + 关闭)', (tester) async {
|
||||||
|
repository.onGetPost = (_) async => Post.fromJson({
|
||||||
|
...samplePostJson(),
|
||||||
|
'media': [
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
samplePostMediaItemJson(
|
||||||
|
assetId: 'a-$i',
|
||||||
|
position: i,
|
||||||
|
isCover: i == 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(PostMediaGrid), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find
|
||||||
|
.descendant(
|
||||||
|
of: find.byType(PostMediaGrid),
|
||||||
|
matching: find.byType(InkWell),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('1/5'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.close));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(PostMediaGrid), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('评论区', () {
|
||||||
|
testWidgets('游标列表:首页渲染 + 触底携游标补页', (tester) async {
|
||||||
|
repository.onListComments = (_, cursor) async {
|
||||||
|
if (cursor == null) {
|
||||||
|
return commentPage(
|
||||||
|
[
|
||||||
|
sampleComment(id: 'c-1', content: '第一条'),
|
||||||
|
sampleComment(id: 'c-2', content: '第二条'),
|
||||||
|
],
|
||||||
|
nextCursor: 'cc1',
|
||||||
|
hasMore: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return commentPage([sampleComment(id: 'c-3', content: '第三条')]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('第一条'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -800));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
repository.calls.where((c) => c.startsWith('comments:')).toList(),
|
||||||
|
['comments:p-1:cursor=null', 'comments:p-1:cursor=cc1'],
|
||||||
|
);
|
||||||
|
expect(find.text('第三条'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('列表失败:话术 + 点按重试恢复', (tester) async {
|
||||||
|
var attempts = 0;
|
||||||
|
repository.onListComments = (_, _) async {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts == 1) throw const ApiNetworkException();
|
||||||
|
return commentPage([sampleComment(content: '恢复后的评论')]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('加载失败,点此重试'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('加载失败,点此重试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('恢复后的评论'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('创建成功:插入列表头 + 计数 +1 + comment_create_succeeded + 清空输入', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
repository.onCreateComment = (_, request) async =>
|
||||||
|
sampleComment(id: 'c-new', content: request.content);
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '沙发!');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byIcon(Icons.send_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.calls, contains('createComment:p-1:沙发!'));
|
||||||
|
expect(find.text('沙发!'), findsOneWidget); // 列表中的新评论。
|
||||||
|
expect(find.text('评论 (4)'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.widget<TextField>(find.byType(TextField)).controller?.text,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
final succeeded = eventsNamed('comment_create_succeeded').single!;
|
||||||
|
expect(succeeded['isReply'], false);
|
||||||
|
expect(succeeded['textLengthBucket'], 'short');
|
||||||
|
expect(succeeded['durationMs'], greaterThanOrEqualTo(0));
|
||||||
|
// Feed 卡片同源计数(跨页一致的另一半在 like 测试)。
|
||||||
|
expect(controller.cachedPost('p-1')!.commentCount, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('创建失败:SnackBar + comment_create_failed,attemptSeq 递增', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
repository.onCreateComment = (_, _) async =>
|
||||||
|
throw const ApiBusinessException(code: 40000, message: 'bad');
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '不合规内容');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byIcon(Icons.send_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('评论内容不合规,请修改后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
// 等 SnackBar 退场(遮挡底部发送钮)后重试第二次。
|
||||||
|
await tester.pump(const Duration(seconds: 5));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.send_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(eventsNamed('comment_create_failed'), [
|
||||||
|
{
|
||||||
|
'failureReason': 'validation_error',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
'errorCode': 40000,
|
||||||
|
'httpStatus': 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'failureReason': 'validation_error',
|
||||||
|
'attemptSeq': 2,
|
||||||
|
'errorCode': 40000,
|
||||||
|
'httpStatus': 400,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// 失败不清空输入、不入列表、不动计数。
|
||||||
|
expect(find.text('评论 (3)'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('删除权限呈现:仅本人评论有「删除」;确认后删除 + 计数 -1', (tester) async {
|
||||||
|
repository.onListComments = (_, _) async => commentPage([
|
||||||
|
sampleComment(
|
||||||
|
id: 'c-mine',
|
||||||
|
author: sampleAuthorJson(userId: 'me', nickname: '我自己'),
|
||||||
|
content: '我的评论',
|
||||||
|
),
|
||||||
|
sampleComment(id: 'c-other', content: '别人的评论'),
|
||||||
|
]);
|
||||||
|
repository.onDeleteComment = (_) async {};
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -400));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// 两条评论,删除入口恰一个(本人条目)。
|
||||||
|
expect(find.byType(CommentTile), findsNWidgets(2));
|
||||||
|
expect(find.text('删除'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('删除'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('删除这条评论?'), findsOneWidget);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '删除'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.calls, contains('deleteComment:c-mine'));
|
||||||
|
expect(find.text('我的评论'), findsNothing);
|
||||||
|
expect(find.text('别人的评论'), findsOneWidget);
|
||||||
|
expect(find.text('评论 (2)'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('删除失败:40301 提示无权限,条目保留', (tester) async {
|
||||||
|
repository.onListComments = (_, _) async => commentPage([
|
||||||
|
sampleComment(
|
||||||
|
id: 'c-mine',
|
||||||
|
author: sampleAuthorJson(userId: 'me', nickname: '我自己'),
|
||||||
|
content: '我的评论',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
repository.onDeleteComment = (_) async =>
|
||||||
|
throw const PostAccessDeniedException(message: 'denied');
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -400));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('删除'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '删除'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('没有权限删除这条评论'), findsOneWidget);
|
||||||
|
expect(find.text('我的评论'), findsOneWidget);
|
||||||
|
expect(find.text('评论 (3)'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('互动(ToggleSync UI 层)', () {
|
||||||
|
testWidgets('点赞乐观翻转即时 +1,成功报 post_liked(source=post_detail)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final like = Completer<LikeState>();
|
||||||
|
repository.onLikeToggle = (_, _) => like.future;
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.favorite_border));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('7'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.favorite), findsOneWidget);
|
||||||
|
|
||||||
|
like.complete(const LikeState(liked: true, likeCount: 7));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(eventsNamed('post_liked'), [
|
||||||
|
{'source': 'post_detail'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('点赞失败:零动画回滚成对恢复 + SnackBar,不报事件', (tester) async {
|
||||||
|
final like = Completer<LikeState>();
|
||||||
|
repository.onLikeToggle = (_, _) => like.future;
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.favorite_border));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('7'), findsOneWidget);
|
||||||
|
|
||||||
|
like.completeError(const ApiNetworkException());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('6'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
|
||||||
|
expect(find.text('操作失败,请重试'), findsOneWidget);
|
||||||
|
expect(eventsNamed('post_liked'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('收藏接线:成功报 post_favorited(source=post_detail)', (tester) async {
|
||||||
|
repository.onBookmarkToggle = (_, target) async =>
|
||||||
|
BookmarkState(bookmarked: target, bookmarkCount: target ? 3 : 2);
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.bookmark_border));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byIcon(Icons.bookmark), findsOneWidget);
|
||||||
|
expect(eventsNamed('post_favorited'), [
|
||||||
|
{'source': 'post_detail'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('跨页一致:详情页点赞,Feed 卡片同帖同帧更新(共享同一实例)', (tester) async {
|
||||||
|
repository.onFeed = (_, _) async =>
|
||||||
|
feedPage([sampleFeedCard(likeCount: 6)]);
|
||||||
|
await controller.refresh();
|
||||||
|
repository.onLikeToggle = (_, target) async =>
|
||||||
|
LikeState(liked: target, likeCount: target ? 7 : 6);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Column(
|
||||||
|
children: [
|
||||||
|
// 模拟 Feed 侧对同一 controller 的消费。
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final card = controller.feed.single;
|
||||||
|
return Text('card:${card.likedByMe}:${card.likeCount}');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: PostDetailPage(
|
||||||
|
controller: controller,
|
||||||
|
postId: 'p-1',
|
||||||
|
currentUserId: 'me',
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('card:false:6'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.favorite_border));
|
||||||
|
await tester.pump();
|
||||||
|
// 乐观写入即同源:卡片副本同帧翻转。
|
||||||
|
expect(find.text('card:true:7'), findsOneWidget);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('card:true:7'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('关注', () {
|
||||||
|
testWidgets('未关注 → 关注:乐观翻转 + user_followed(post_detail)', (tester) async {
|
||||||
|
repository.onFollowToggle = (_, target) async =>
|
||||||
|
FollowState(following: target, followerCount: target ? 2 : 1);
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('+ 关注'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('+ 关注'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('已关注'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repository.calls, contains('follow:u-1'));
|
||||||
|
expect(eventsNamed('user_followed'), [
|
||||||
|
{'source': 'post_detail'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('已关注 → 取关:确认弹窗 + user_unfollowed', (tester) async {
|
||||||
|
repository.onGetFollowStats = (_) async => const FollowStats(
|
||||||
|
followerCount: 2,
|
||||||
|
followingCount: 2,
|
||||||
|
followedByMe: true,
|
||||||
|
);
|
||||||
|
repository.onFollowToggle = (_, target) async =>
|
||||||
|
FollowState(following: target, followerCount: target ? 2 : 1);
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('已关注'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('已关注'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('不再关注 TA?'), findsOneWidget);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '不再关注'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('+ 关注'), findsOneWidget);
|
||||||
|
expect(repository.calls, contains('unfollow:u-1'));
|
||||||
|
expect(eventsNamed('user_unfollowed'), [
|
||||||
|
{'source': 'post_detail'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('关注失败:回滚直接跳变 + SnackBar', (tester) async {
|
||||||
|
final follow = Completer<FollowState>();
|
||||||
|
repository.onFollowToggle = (_, _) => follow.future;
|
||||||
|
|
||||||
|
await tester.pumpWidget(detailApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('+ 关注'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('已关注'), findsOneWidget);
|
||||||
|
|
||||||
|
follow.completeError(const ApiNetworkException());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('+ 关注'), findsOneWidget);
|
||||||
|
expect(find.text('操作失败,请重试'), findsOneWidget);
|
||||||
|
expect(eventsNamed('user_followed'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('本人帖:不渲染关注钮、不拉 follow-stats', (tester) async {
|
||||||
|
await tester.pumpWidget(detailApp(currentUserId: 'u-1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('+ 关注'), findsNothing);
|
||||||
|
expect(find.text('已关注'), findsNothing);
|
||||||
|
expect(
|
||||||
|
repository.calls.where((c) => c.startsWith('followStats:')),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
|
||||||
|
/// community 域测试样本 JSON(字段与契约 v1.3.0 逐字一致)。
|
||||||
|
|
||||||
|
Map<String, dynamic> sampleAuthorJson({
|
||||||
|
String userId = 'u-1',
|
||||||
|
String? nickname = '毛毛的铲屎官',
|
||||||
|
String? avatarUrl = 'https://minio.local/avatar.jpg?X-Amz-Signature=sig',
|
||||||
|
}) => {'userId': userId, 'nickname': nickname, 'avatarUrl': avatarUrl};
|
||||||
|
|
||||||
|
Map<String, dynamic> samplePostMediaItemJson({
|
||||||
|
String assetId = 'a-1',
|
||||||
|
int position = 0,
|
||||||
|
bool isCover = true,
|
||||||
|
}) => {
|
||||||
|
'assetId': assetId,
|
||||||
|
'position': position,
|
||||||
|
'isCover': isCover,
|
||||||
|
'url': 'https://minio.local/p.jpg?X-Amz-Signature=sig',
|
||||||
|
'widthPx': 1080,
|
||||||
|
'heightPx': 810,
|
||||||
|
'caption': '晒太阳',
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> samplePostJson({
|
||||||
|
String id = 'p-1',
|
||||||
|
String status = 'published',
|
||||||
|
bool likedByMe = false,
|
||||||
|
int likeCount = 6,
|
||||||
|
bool bookmarkedByMe = false,
|
||||||
|
int bookmarkCount = 2,
|
||||||
|
int version = 1,
|
||||||
|
}) => {
|
||||||
|
'id': id,
|
||||||
|
'author': sampleAuthorJson(),
|
||||||
|
'petId': 'pet-1',
|
||||||
|
'category': 'general',
|
||||||
|
'title': '今天的豆豆',
|
||||||
|
'content': '晒了一下午太阳。',
|
||||||
|
'status': status,
|
||||||
|
'visibility': 'public',
|
||||||
|
'media': [samplePostMediaItemJson()],
|
||||||
|
'likeCount': likeCount,
|
||||||
|
'commentCount': 3,
|
||||||
|
'bookmarkCount': bookmarkCount,
|
||||||
|
'likedByMe': likedByMe,
|
||||||
|
'bookmarkedByMe': bookmarkedByMe,
|
||||||
|
'createdAt': '2026-09-08T10:00:00.000Z',
|
||||||
|
'updatedAt': '2026-09-08T10:05:00.000Z',
|
||||||
|
'publishedAt': status == 'published' ? '2026-09-08T10:05:00.000Z' : null,
|
||||||
|
'version': version,
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> sampleFeedCardJson({
|
||||||
|
String id = 'p-1',
|
||||||
|
bool likedByMe = false,
|
||||||
|
int likeCount = 6,
|
||||||
|
bool bookmarkedByMe = false,
|
||||||
|
int bookmarkCount = 2,
|
||||||
|
}) => {
|
||||||
|
'id': id,
|
||||||
|
'author': sampleAuthorJson(),
|
||||||
|
'category': 'general',
|
||||||
|
'title': '今天的豆豆',
|
||||||
|
'contentPreview': '晒了一下午太阳。',
|
||||||
|
'coverImage': samplePostMediaItemJson(),
|
||||||
|
'mediaCount': 1,
|
||||||
|
'likeCount': likeCount,
|
||||||
|
'commentCount': 3,
|
||||||
|
'bookmarkCount': bookmarkCount,
|
||||||
|
'likedByMe': likedByMe,
|
||||||
|
'bookmarkedByMe': bookmarkedByMe,
|
||||||
|
'publishedAt': '2026-09-08T10:05:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> sampleCommentJson({
|
||||||
|
String id = 'c-1',
|
||||||
|
Map<String, dynamic>? author,
|
||||||
|
Map<String, dynamic>? replyToUser,
|
||||||
|
String content = '好可爱!',
|
||||||
|
}) => {
|
||||||
|
'id': id,
|
||||||
|
'postId': 'p-1',
|
||||||
|
'author': author ?? sampleAuthorJson(),
|
||||||
|
'replyToUser': replyToUser,
|
||||||
|
'content': content,
|
||||||
|
'createdAt': '2026-09-08T11:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> sampleUploadCredentialsJson() => {
|
||||||
|
'assetId': 'a-1',
|
||||||
|
'uploadUrl':
|
||||||
|
'https://minio.local/patbond-media/post_image/a-1?X-Amz-Signature=sig',
|
||||||
|
'method': 'PUT',
|
||||||
|
'requiredHeaders': {'Content-Type': 'image/jpeg'},
|
||||||
|
'expiresAt': '2026-09-08T10:10:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> sampleMediaAssetJson({String status = 'ready'}) => {
|
||||||
|
'id': 'a-1',
|
||||||
|
'kind': 'image',
|
||||||
|
'purpose': 'post_image',
|
||||||
|
'mimeType': 'image/jpeg',
|
||||||
|
'byteSize': 204800,
|
||||||
|
'widthPx': 1080,
|
||||||
|
'heightPx': 810,
|
||||||
|
'status': status,
|
||||||
|
'url': status == 'ready'
|
||||||
|
? 'https://minio.local/p.jpg?X-Amz-Signature=sig'
|
||||||
|
: null,
|
||||||
|
'readyAt': status == 'ready' ? '2026-09-08T10:06:00.000Z' : null,
|
||||||
|
'createdAt': '2026-09-08T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, Object?> cursorPageJson(
|
||||||
|
List<Map<String, dynamic>> items, {
|
||||||
|
String? nextCursor,
|
||||||
|
bool hasMore = false,
|
||||||
|
}) => {'items': items, 'nextCursor': nextCursor, 'hasMore': hasMore};
|
||||||
|
|
||||||
|
FeedCard sampleFeedCard({
|
||||||
|
String id = 'p-1',
|
||||||
|
bool likedByMe = false,
|
||||||
|
int likeCount = 6,
|
||||||
|
}) => FeedCard.fromJson(
|
||||||
|
sampleFeedCardJson(id: id, likedByMe: likedByMe, likeCount: likeCount),
|
||||||
|
);
|
||||||
|
|
||||||
|
CursorPage<FeedCard> feedPage(
|
||||||
|
List<FeedCard> items, {
|
||||||
|
String? nextCursor,
|
||||||
|
bool hasMore = false,
|
||||||
|
}) => CursorPage(items: items, nextCursor: nextCursor, hasMore: hasMore);
|
||||||
|
|
||||||
|
Post samplePost({
|
||||||
|
String id = 'p-1',
|
||||||
|
bool likedByMe = false,
|
||||||
|
int likeCount = 6,
|
||||||
|
bool bookmarkedByMe = false,
|
||||||
|
int bookmarkCount = 2,
|
||||||
|
}) => Post.fromJson(
|
||||||
|
samplePostJson(
|
||||||
|
id: id,
|
||||||
|
likedByMe: likedByMe,
|
||||||
|
likeCount: likeCount,
|
||||||
|
bookmarkedByMe: bookmarkedByMe,
|
||||||
|
bookmarkCount: bookmarkCount,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
PostComment sampleComment({
|
||||||
|
String id = 'c-1',
|
||||||
|
Map<String, dynamic>? author,
|
||||||
|
Map<String, dynamic>? replyToUser,
|
||||||
|
String content = '好可爱!',
|
||||||
|
}) => PostComment.fromJson(
|
||||||
|
sampleCommentJson(
|
||||||
|
id: id,
|
||||||
|
author: author,
|
||||||
|
replyToUser: replyToUser,
|
||||||
|
content: content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
CursorPage<PostComment> commentPage(
|
||||||
|
List<PostComment> items, {
|
||||||
|
String? nextCursor,
|
||||||
|
bool hasMore = false,
|
||||||
|
}) => CursorPage(items: items, nextCursor: nextCursor, hasMore: hasMore);
|
||||||
|
|
||||||
|
/// 草稿样本(T3-17 发布页草稿恢复用;media 可清空为纯文字草稿)。
|
||||||
|
Post sampleDraft({
|
||||||
|
String id = 'draft-1',
|
||||||
|
int version = 3,
|
||||||
|
String content = '草稿正文',
|
||||||
|
bool withMedia = true,
|
||||||
|
}) => Post.fromJson({
|
||||||
|
...samplePostJson(id: id, status: 'draft', version: version),
|
||||||
|
'content': content,
|
||||||
|
if (!withMedia) 'media': const <Map<String, dynamic>>[],
|
||||||
|
});
|
||||||
|
|
||||||
|
CursorPage<Post> postPage(List<Post> items) =>
|
||||||
|
CursorPage(items: items, nextCursor: null, hasMore: false);
|
||||||
|
|
||||||
|
/// 假仓库:controller 测试注入行为并记录调用(Completer 控时序)。
|
||||||
|
/// 未注入 handler 的方法一律 UnimplementedError(误触发即测试失败)。
|
||||||
|
class FakeCommunityRepository implements CommunityRepository {
|
||||||
|
/// 调用日志,如 `feed:cursor=null`、`like:p-1`、`unlike:p-1`。
|
||||||
|
final List<String> calls = [];
|
||||||
|
|
||||||
|
Future<CursorPage<FeedCard>> Function(int? limit, String? cursor)? onFeed;
|
||||||
|
Future<LikeState> Function(String postId, bool target)? onLikeToggle;
|
||||||
|
Future<BookmarkState> Function(String postId, bool target)? onBookmarkToggle;
|
||||||
|
Future<Post> Function(String postId)? onGetPost;
|
||||||
|
Future<CursorPage<PostComment>> Function(String postId, String? cursor)?
|
||||||
|
onListComments;
|
||||||
|
Future<PostComment> Function(String postId, CreateCommentRequest request)?
|
||||||
|
onCreateComment;
|
||||||
|
Future<void> Function(String commentId)? onDeleteComment;
|
||||||
|
Future<FollowState> Function(String userId, bool target)? onFollowToggle;
|
||||||
|
Future<FollowStats> Function(String userId)? onGetFollowStats;
|
||||||
|
Future<MediaUploadCredentials> Function(CreateMediaUploadRequest request)?
|
||||||
|
onCreateMediaUpload;
|
||||||
|
Future<MediaAsset> Function(String assetId)? onCompleteMediaUpload;
|
||||||
|
Future<Post> Function(CreatePostRequest request, String? idempotencyKey)?
|
||||||
|
onCreatePost;
|
||||||
|
Future<Post> Function(String postId, UpdatePostRequest request)? onUpdatePost;
|
||||||
|
Future<void> Function(String postId)? onDeletePost;
|
||||||
|
Future<CursorPage<Post>> Function(PostStatus? status)? onListMyPosts;
|
||||||
|
|
||||||
|
/// createPost 收到的幂等键顺序(同键重放断言用)。
|
||||||
|
final List<String?> idempotencyKeys = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor}) {
|
||||||
|
calls.add('feed:cursor=$cursor');
|
||||||
|
return onFeed!(limit, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> likePost(String postId) {
|
||||||
|
calls.add('like:$postId');
|
||||||
|
return onLikeToggle!(postId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> unlikePost(String postId) {
|
||||||
|
calls.add('unlike:$postId');
|
||||||
|
return onLikeToggle!(postId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> bookmarkPost(String postId) {
|
||||||
|
calls.add('bookmark:$postId');
|
||||||
|
return onBookmarkToggle!(postId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> unbookmarkPost(String postId) {
|
||||||
|
calls.add('unbookmark:$postId');
|
||||||
|
return onBookmarkToggle!(postId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> getPost(String postId) {
|
||||||
|
calls.add('getPost:$postId');
|
||||||
|
return onGetPost!(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaUploadCredentials> createMediaUpload(
|
||||||
|
CreateMediaUploadRequest request,
|
||||||
|
) {
|
||||||
|
calls.add('createUpload:${request.mimeType}:${request.byteSize}');
|
||||||
|
return onCreateMediaUpload!(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaAsset> completeMediaUpload(String assetId) {
|
||||||
|
calls.add('complete:$assetId');
|
||||||
|
return onCompleteMediaUpload!(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> createPost(CreatePostRequest request, {String? idempotencyKey}) {
|
||||||
|
calls.add('createPost:${request.status?.name}:${request.content}');
|
||||||
|
idempotencyKeys.add(idempotencyKey);
|
||||||
|
return onCreatePost!(request, idempotencyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> updatePost(String postId, UpdatePostRequest request) {
|
||||||
|
calls.add(
|
||||||
|
'updatePost:$postId:v${request.version}:publish=${request.publish}',
|
||||||
|
);
|
||||||
|
return onUpdatePost!(postId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deletePost(String postId) {
|
||||||
|
calls.add('deletePost:$postId');
|
||||||
|
return onDeletePost!(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<Post>> listMyPosts({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
PostStatus? status,
|
||||||
|
}) {
|
||||||
|
calls.add('listMyPosts:${status?.name}');
|
||||||
|
return onListMyPosts == null
|
||||||
|
? Future.value(
|
||||||
|
const CursorPage(items: [], nextCursor: null, hasMore: false),
|
||||||
|
)
|
||||||
|
: onListMyPosts!(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<PostComment>> listComments(
|
||||||
|
String postId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) {
|
||||||
|
calls.add('comments:$postId:cursor=$cursor');
|
||||||
|
return onListComments!(postId, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PostComment> createComment(
|
||||||
|
String postId,
|
||||||
|
CreateCommentRequest request,
|
||||||
|
) {
|
||||||
|
calls.add('createComment:$postId:${request.content}');
|
||||||
|
return onCreateComment!(postId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteComment(String commentId) {
|
||||||
|
calls.add('deleteComment:$commentId');
|
||||||
|
return onDeleteComment!(commentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> followUser(String userId) {
|
||||||
|
calls.add('follow:$userId');
|
||||||
|
return onFollowToggle!(userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> unfollowUser(String userId) {
|
||||||
|
calls.add('unfollow:$userId');
|
||||||
|
return onFollowToggle!(userId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowStats> getFollowStats(String userId) {
|
||||||
|
calls.add('followStats:$userId');
|
||||||
|
return onGetFollowStats!(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_compression.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_picking.dart';
|
||||||
|
|
||||||
|
/// media 上传链路测试件(T3-13):假选择器/压缩器/直传客户端,
|
||||||
|
/// 全部支持 Completer 控时序(helpers 既有先例)。
|
||||||
|
|
||||||
|
PickedMediaImage pickedImage({int seed = 1, int size = 64}) => PickedMediaImage(
|
||||||
|
bytes: Uint8List.fromList(List.filled(size, seed)),
|
||||||
|
name: 'img-$seed.jpg',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 1x1 真 PNG 字节(70B):widget 测试里九宫格格子要真解码出图,
|
||||||
|
/// 用可解码的最小合法图片避免 Image.memory 走 errorBuilder 兜底。
|
||||||
|
final Uint8List tinyPngBytes = base64Decode(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAF'
|
||||||
|
'AAH/q842iQAAAABJRU5ErkJggg==',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 可解码预览的选图(发布页 widget 测试用)。
|
||||||
|
PickedMediaImage decodablePickedImage({int seed = 1}) =>
|
||||||
|
PickedMediaImage(bytes: tinyPngBytes, name: 'img-$seed.png');
|
||||||
|
|
||||||
|
MediaUploadCredentials credentials({
|
||||||
|
String assetId = 'a-1',
|
||||||
|
DateTime? expiresAt,
|
||||||
|
}) => MediaUploadCredentials(
|
||||||
|
assetId: assetId,
|
||||||
|
uploadUrl:
|
||||||
|
'http://minio.local/patbond-media/post_image/$assetId?X-Amz-Signature=sig',
|
||||||
|
method: 'PUT',
|
||||||
|
requiredHeaders: const {'Content-Type': 'image/jpeg'},
|
||||||
|
expiresAt: expiresAt ?? DateTime.now().add(const Duration(minutes: 10)),
|
||||||
|
);
|
||||||
|
|
||||||
|
MediaAsset readyAsset({String assetId = 'a-1'}) => MediaAsset(
|
||||||
|
id: assetId,
|
||||||
|
kind: MediaKind.image,
|
||||||
|
purpose: 'post_image',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
byteSize: 1024,
|
||||||
|
widthPx: 1080,
|
||||||
|
heightPx: 810,
|
||||||
|
status: MediaAssetStatus.ready,
|
||||||
|
url: 'http://minio.local/p.jpg?X-Amz-Signature=sig',
|
||||||
|
readyAt: DateTime.utc(2026, 9, 9),
|
||||||
|
createdAt: DateTime.utc(2026, 9, 9),
|
||||||
|
);
|
||||||
|
|
||||||
|
class FakeMediaImagePicker implements MediaImagePicker {
|
||||||
|
FakeMediaImagePicker(this.results);
|
||||||
|
|
||||||
|
final List<PickedMediaImage> results;
|
||||||
|
final List<int> limits = [];
|
||||||
|
|
||||||
|
/// 非 null 时 pickImages 挂起等待(picking 态观测用)。
|
||||||
|
Completer<void>? gate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PickedMediaImage>> pickImages({required int limit}) async {
|
||||||
|
limits.add(limit);
|
||||||
|
await gate?.future;
|
||||||
|
return results.take(limit).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 假压缩器:默认原样透传出 image/jpeg;[sizePerQuality] 指定各降质档
|
||||||
|
/// 的产物大小(超限阶梯测试用)。
|
||||||
|
class FakeMediaCompressor implements MediaImageCompressor {
|
||||||
|
final List<int> qualities = [];
|
||||||
|
Map<int, int>? sizePerQuality;
|
||||||
|
Exception? error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CompressedMediaImage> compress(
|
||||||
|
PickedMediaImage source, {
|
||||||
|
required int quality,
|
||||||
|
}) async {
|
||||||
|
qualities.add(quality);
|
||||||
|
if (error != null) throw error!;
|
||||||
|
final size = sizePerQuality?[quality];
|
||||||
|
return CompressedMediaImage(
|
||||||
|
bytes: size == null
|
||||||
|
? source.bytes
|
||||||
|
: Uint8List.fromList(List.filled(size, 0)),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次直传调用的观测句柄(进度回调驱动 + 结局手动控制)。
|
||||||
|
class DirectUploadCall {
|
||||||
|
DirectUploadCall({
|
||||||
|
required this.url,
|
||||||
|
required this.headers,
|
||||||
|
required this.bytes,
|
||||||
|
required this.onProgress,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
final Uint8List bytes;
|
||||||
|
final void Function(int sent, int total)? onProgress;
|
||||||
|
final Completer<void> completer = Completer<void>();
|
||||||
|
|
||||||
|
void emitProgress(int sent, int total) => onProgress?.call(sent, total);
|
||||||
|
void succeed() => completer.complete();
|
||||||
|
void fail(MediaDirectUploadException error) => completer.completeError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDirectUploadClient implements MediaDirectUploadClient {
|
||||||
|
final List<DirectUploadCall> calls = [];
|
||||||
|
|
||||||
|
/// 非 null 时每次 put 自动以该结果收尾(null = 自动成功);
|
||||||
|
/// 设为 manual 后由测试经 [calls] 手动驱动。
|
||||||
|
bool manual = false;
|
||||||
|
final List<MediaDirectUploadException?> scriptedOutcomes = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> put({
|
||||||
|
required String url,
|
||||||
|
required Map<String, String> headers,
|
||||||
|
required Uint8List bytes,
|
||||||
|
void Function(int sent, int total)? onProgress,
|
||||||
|
}) {
|
||||||
|
final call = DirectUploadCall(
|
||||||
|
url: url,
|
||||||
|
headers: headers,
|
||||||
|
bytes: bytes,
|
||||||
|
onProgress: onProgress,
|
||||||
|
);
|
||||||
|
calls.add(call);
|
||||||
|
if (!manual) {
|
||||||
|
final outcome = scriptedOutcomes.isEmpty
|
||||||
|
? null
|
||||||
|
: scriptedOutcomes.removeAt(0);
|
||||||
|
if (outcome == null) {
|
||||||
|
call.succeed();
|
||||||
|
} else {
|
||||||
|
call.fail(outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return call.completer.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,6 +163,23 @@ Vaccination buildVaccination(
|
|||||||
}) =>
|
}) =>
|
||||||
Vaccination.fromJson({...sampleVaccinationJson(), 'id': id, ...overrides});
|
Vaccination.fromJson({...sampleVaccinationJson(), 'id': id, ...overrides});
|
||||||
|
|
||||||
|
/// 快速构造健康事件。
|
||||||
|
HealthEvent buildHealthEvent(
|
||||||
|
String id, {
|
||||||
|
Map<String, Object?> overrides = const {},
|
||||||
|
}) =>
|
||||||
|
HealthEvent.fromJson({...sampleHealthEventJson(), 'id': id, ...overrides});
|
||||||
|
|
||||||
|
/// 快速构造照护提醒。
|
||||||
|
CareReminder buildReminder(
|
||||||
|
String id, {
|
||||||
|
Map<String, Object?> overrides = const {},
|
||||||
|
}) => CareReminder.fromJson({
|
||||||
|
...sampleCareReminderJson(),
|
||||||
|
'id': id,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
/// 快速构造摘要(缺省为「三聚合齐备」样本;overrides 可置 null 验证空态)。
|
/// 快速构造摘要(缺省为「三聚合齐备」样本;overrides 可置 null 验证空态)。
|
||||||
PetSummary buildSummary({Map<String, Object?> overrides = const {}}) =>
|
PetSummary buildSummary({Map<String, Object?> overrides = const {}}) =>
|
||||||
PetSummary.fromJson({...samplePetSummaryJson(), ...overrides});
|
PetSummary.fromJson({...samplePetSummaryJson(), ...overrides});
|
||||||
@@ -184,6 +201,20 @@ class FakePetsRepository implements PetsRepository {
|
|||||||
Future<List<Vaccination>> Function(String)? listVaccinationsHandler;
|
Future<List<Vaccination>> Function(String)? listVaccinationsHandler;
|
||||||
Future<Vaccination> Function(String, CreateVaccinationRequest)?
|
Future<Vaccination> Function(String, CreateVaccinationRequest)?
|
||||||
createVaccinationHandler;
|
createVaccinationHandler;
|
||||||
|
Future<Vaccination> Function(String, UpdateVaccinationRequest)?
|
||||||
|
updateVaccinationHandler;
|
||||||
|
Future<CursorPage<HealthEvent>> Function(String, int?, String?)?
|
||||||
|
listHealthEventsHandler;
|
||||||
|
Future<HealthEvent> Function(String, CreateHealthEventRequest)?
|
||||||
|
createHealthEventHandler;
|
||||||
|
Future<HealthEvent> Function(String, UpdateHealthEventRequest)?
|
||||||
|
updateHealthEventHandler;
|
||||||
|
Future<List<CareReminder>> Function(String, CareReminderStatus?)?
|
||||||
|
listCareRemindersHandler;
|
||||||
|
Future<CareReminder> Function(String, CreateCareReminderRequest)?
|
||||||
|
createCareReminderHandler;
|
||||||
|
Future<CareReminder> Function(String, UpdateCareReminderRequest)?
|
||||||
|
updateCareReminderHandler;
|
||||||
Future<PetSummary> Function(String, String?)? getPetSummaryHandler;
|
Future<PetSummary> Function(String, String?)? getPetSummaryHandler;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -233,6 +264,52 @@ class FakePetsRepository implements PetsRepository {
|
|||||||
CreateVaccinationRequest request,
|
CreateVaccinationRequest request,
|
||||||
) => createVaccinationHandler!(petId, request);
|
) => createVaccinationHandler!(petId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Vaccination> updateVaccination(
|
||||||
|
String vaccinationId,
|
||||||
|
UpdateVaccinationRequest request,
|
||||||
|
) => updateVaccinationHandler!(vaccinationId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<HealthEvent>> listHealthEvents(
|
||||||
|
String petId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) => listHealthEventsHandler!(petId, limit, cursor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthEvent> createHealthEvent(
|
||||||
|
String petId,
|
||||||
|
CreateHealthEventRequest request,
|
||||||
|
) => createHealthEventHandler!(petId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthEvent> updateHealthEvent(
|
||||||
|
String eventId,
|
||||||
|
UpdateHealthEventRequest request,
|
||||||
|
) => updateHealthEventHandler!(eventId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CareReminder>> listCareReminders(
|
||||||
|
String petId, {
|
||||||
|
CareReminderStatus? status,
|
||||||
|
}) =>
|
||||||
|
listCareRemindersHandler?.call(petId, status) ??
|
||||||
|
// 缺省空列表:既有详情页测试不必逐个注入。
|
||||||
|
Future.value(const []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CareReminder> createCareReminder(
|
||||||
|
String petId,
|
||||||
|
CreateCareReminderRequest request,
|
||||||
|
) => createCareReminderHandler!(petId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CareReminder> updateCareReminder(
|
||||||
|
String reminderId,
|
||||||
|
UpdateCareReminderRequest request,
|
||||||
|
) => updateCareReminderHandler!(reminderId, request);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PetSummary> getPetSummary(String petId, {String? tz}) =>
|
Future<PetSummary> getPetSummary(String petId, {String? tz}) =>
|
||||||
getPetSummaryHandler?.call(petId, tz) ??
|
getPetSummaryHandler?.call(petId, tz) ??
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/token_refresher.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/auth_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../helpers/auth_test_helpers.dart';
|
||||||
|
|
||||||
|
/// T3-15/16 compose 真链路冒烟(默认跳过,不计入常规测试套件):
|
||||||
|
///
|
||||||
|
/// ```bash
|
||||||
|
/// # 先起后端六容器(patbond-api 仓库根):
|
||||||
|
/// # ./deploy/init-secrets.sh
|
||||||
|
/// # JAVA_HOME=<JDK17> ./mvnw -DskipTests package && docker compose up -d --build
|
||||||
|
/// PATBOND_DETAIL_SMOKE=1 flutter test test/smoke/detail_interactions_smoke_test.dart
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 覆盖:发帖 → 点赞(含重复施加幂等)→ 收藏/取消 → 评论创建
|
||||||
|
/// (Idempotency-Key)→ 仅作者删除评论 → 计数权威对账;再走一轮
|
||||||
|
/// **断网点赞回滚**——[CommunityController]/ToggleSync 生产实现 +
|
||||||
|
/// 生产 ApiClient 错误链,community 端点切至不可达端口模拟断网
|
||||||
|
/// (连接拒绝走真实 ApiNetworkException 路径),断言乐观翻转即刻
|
||||||
|
/// 可见、失败后快照回滚、网络恢复后权威终态收敛。
|
||||||
|
void main() {
|
||||||
|
final enabled = Platform.environment['PATBOND_DETAIL_SMOKE'] == '1';
|
||||||
|
final env = Platform.environment;
|
||||||
|
final authBase = env['PATBOND_SMOKE_AUTH_BASE'] ?? 'http://127.0.0.1:8081';
|
||||||
|
final communityBase =
|
||||||
|
env['PATBOND_SMOKE_COMMUNITY_BASE'] ?? 'http://127.0.0.1:8084';
|
||||||
|
|
||||||
|
test(
|
||||||
|
'点赞/收藏/评论/删评真链路一轮 + 断网点赞回滚',
|
||||||
|
() async {
|
||||||
|
// ---- 注册一次性账号(随机凭据,不落任何持久化)----
|
||||||
|
final dio = Dio(BaseOptions(validateStatus: (_) => true));
|
||||||
|
final seed = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final register = await dio.post<Map<String, dynamic>>(
|
||||||
|
'$authBase/api/v1/auth/register',
|
||||||
|
data: {
|
||||||
|
'username': 'ismoke$seed',
|
||||||
|
'phone': '+86138${(seed % 100000000).toString().padLeft(8, '0')}',
|
||||||
|
'password': 'Smoke1234!$seed',
|
||||||
|
},
|
||||||
|
options: Options(
|
||||||
|
headers: {
|
||||||
|
'Idempotency-Key': const Uuid().v4(),
|
||||||
|
'X-Device-Id': const Uuid().v4(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(register.data?['code'], 0, reason: '注册失败:${register.data}');
|
||||||
|
|
||||||
|
final session = SessionManager(store: InMemoryTokenStore());
|
||||||
|
await session.updateTokens(
|
||||||
|
AuthTokens.fromJson(register.data!['data'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
final refresher = TokenRefresher(
|
||||||
|
dio: buildPatbondDio(session: session, baseUrl: authBase),
|
||||||
|
session: session,
|
||||||
|
);
|
||||||
|
final live = ApiCommunityRepository(
|
||||||
|
api: ApiClient(
|
||||||
|
dio: buildPatbondDio(session: session, baseUrl: communityBase),
|
||||||
|
session: session,
|
||||||
|
refresher: refresher,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// 「断网」形态:community 域切至不可达端口(连接拒绝 → 生产
|
||||||
|
// ApiClient 映射 ApiNetworkException,与真实断网同一异常链)。
|
||||||
|
final dead = ApiCommunityRepository(
|
||||||
|
api: ApiClient(
|
||||||
|
dio: buildPatbondDio(session: session, baseUrl: 'http://127.0.0.1:9'),
|
||||||
|
session: session,
|
||||||
|
refresher: refresher,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final switchable = _SwitchableRepository(live);
|
||||||
|
|
||||||
|
// ---- 发帖(published,纯文字)----
|
||||||
|
final post = await live.createPost(
|
||||||
|
CreatePostRequest(
|
||||||
|
content: 'T3-15/16 互动冒烟 $seed',
|
||||||
|
status: PostStatus.published,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(post.likeCount, 0);
|
||||||
|
expect(post.commentCount, 0);
|
||||||
|
|
||||||
|
// ---- 点赞:施加 / 重复施加幂等,权威终态 ----
|
||||||
|
var like = await live.likePost(post.id);
|
||||||
|
expect(like.liked, true);
|
||||||
|
expect(like.likeCount, 1);
|
||||||
|
like = await live.likePost(post.id);
|
||||||
|
expect(like.likeCount, 1, reason: '重复 PUT 不重复计数');
|
||||||
|
|
||||||
|
// ---- 收藏 / 取消 ----
|
||||||
|
var bookmark = await live.bookmarkPost(post.id);
|
||||||
|
expect(bookmark.bookmarked, true);
|
||||||
|
expect(bookmark.bookmarkCount, 1);
|
||||||
|
bookmark = await live.unbookmarkPost(post.id);
|
||||||
|
expect(bookmark.bookmarked, false);
|
||||||
|
expect(bookmark.bookmarkCount, 0);
|
||||||
|
|
||||||
|
// ---- 评论创建(Idempotency-Key 由仓库层携带)→ 计数 +1 ----
|
||||||
|
final comment = await live.createComment(
|
||||||
|
post.id,
|
||||||
|
const CreateCommentRequest(content: '冒烟评论:真链路一轮'),
|
||||||
|
);
|
||||||
|
expect(comment.content, '冒烟评论:真链路一轮');
|
||||||
|
var fresh = await live.getPost(post.id);
|
||||||
|
expect(fresh.commentCount, 1);
|
||||||
|
|
||||||
|
// ---- 仅作者删除评论 → 计数 -1、列表剔除 ----
|
||||||
|
await live.deleteComment(comment.id);
|
||||||
|
fresh = await live.getPost(post.id);
|
||||||
|
expect(fresh.commentCount, 0);
|
||||||
|
final comments = await live.listComments(post.id);
|
||||||
|
expect(comments.items, isEmpty);
|
||||||
|
|
||||||
|
// ---- 断网点赞回滚(生产 CommunityController + ToggleSync)----
|
||||||
|
final controller = CommunityController(repository: switchable);
|
||||||
|
await controller.refresh();
|
||||||
|
FeedCard card() => controller.feed.firstWhere((c) => c.id == post.id);
|
||||||
|
expect(card().likedByMe, true);
|
||||||
|
expect(card().likeCount, 1);
|
||||||
|
|
||||||
|
switchable.target = dead; // 拔网线。
|
||||||
|
final errored = Completer<void>();
|
||||||
|
controller.addListener(() {
|
||||||
|
if (controller.toggleError != null && !errored.isCompleted) {
|
||||||
|
errored.complete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
controller.toggleLike(post.id);
|
||||||
|
// 乐观翻转同帧可见。
|
||||||
|
expect(card().likedByMe, false);
|
||||||
|
expect(card().likeCount, 0);
|
||||||
|
|
||||||
|
await errored.future.timeout(const Duration(seconds: 30));
|
||||||
|
// 快照回滚成对恢复 + 一次性错误可供 SnackBar 消费。
|
||||||
|
expect(card().likedByMe, true);
|
||||||
|
expect(card().likeCount, 1);
|
||||||
|
expect(controller.toggleError, isA<ApiNetworkException>());
|
||||||
|
controller.clearToggleError();
|
||||||
|
|
||||||
|
// ---- 网络恢复:取消点赞收敛到服务端权威终态 ----
|
||||||
|
switchable.target = live;
|
||||||
|
controller.toggleLike(post.id);
|
||||||
|
expect(card().likedByMe, false); // 乐观翻转。
|
||||||
|
// 轮询服务端权威终态(乐观值不作为收敛依据)。
|
||||||
|
final deadline = DateTime.now().add(const Duration(seconds: 15));
|
||||||
|
Post settled = await live.getPost(post.id);
|
||||||
|
while (settled.likedByMe) {
|
||||||
|
expect(DateTime.now().isBefore(deadline), true, reason: '收敛超时');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
settled = await live.getPost(post.id);
|
||||||
|
}
|
||||||
|
expect(settled.likedByMe, false);
|
||||||
|
expect(settled.likeCount, 0);
|
||||||
|
expect(controller.toggleError, isNull);
|
||||||
|
expect(card().likedByMe, false);
|
||||||
|
controller.dispose();
|
||||||
|
},
|
||||||
|
skip: enabled ? false : '设 PATBOND_DETAIL_SMOKE=1 且后端六容器在本机运行时才执行',
|
||||||
|
timeout: const Timeout(Duration(minutes: 3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可切换目标的仓库代理(断网模拟:live ↔ dead 端点整体切换)。
|
||||||
|
class _SwitchableRepository implements CommunityRepository {
|
||||||
|
_SwitchableRepository(this.target);
|
||||||
|
|
||||||
|
CommunityRepository target;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaUploadCredentials> createMediaUpload(
|
||||||
|
CreateMediaUploadRequest request,
|
||||||
|
) => target.createMediaUpload(request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaAsset> completeMediaUpload(String assetId) =>
|
||||||
|
target.completeMediaUpload(assetId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> createPost(
|
||||||
|
CreatePostRequest request, {
|
||||||
|
String? idempotencyKey,
|
||||||
|
}) => target.createPost(request, idempotencyKey: idempotencyKey);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> getPost(String postId) => target.getPost(postId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> updatePost(String postId, UpdatePostRequest request) =>
|
||||||
|
target.updatePost(postId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deletePost(String postId) => target.deletePost(postId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<Post>> listMyPosts({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
PostStatus? status,
|
||||||
|
}) => target.listMyPosts(limit: limit, cursor: cursor, status: status);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor}) =>
|
||||||
|
target.getFeed(limit: limit, cursor: cursor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<PostComment>> listComments(
|
||||||
|
String postId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) => target.listComments(postId, limit: limit, cursor: cursor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PostComment> createComment(
|
||||||
|
String postId,
|
||||||
|
CreateCommentRequest request,
|
||||||
|
) => target.createComment(postId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteComment(String commentId) =>
|
||||||
|
target.deleteComment(commentId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> likePost(String postId) => target.likePost(postId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> unlikePost(String postId) => target.unlikePost(postId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> bookmarkPost(String postId) =>
|
||||||
|
target.bookmarkPost(postId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> unbookmarkPost(String postId) =>
|
||||||
|
target.unbookmarkPost(postId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor}) =>
|
||||||
|
target.listMyBookmarks(limit: limit, cursor: cursor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> followUser(String userId) => target.followUser(userId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> unfollowUser(String userId) =>
|
||||||
|
target.unfollowUser(userId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowStats> getFollowStats(String userId) =>
|
||||||
|
target.getFollowStats(userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/token_refresher.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/auth_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_compression.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_picking.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../helpers/auth_test_helpers.dart';
|
||||||
|
|
||||||
|
/// T3-13 compose 真链路冒烟(默认跳过,不计入常规测试套件):
|
||||||
|
///
|
||||||
|
/// ```bash
|
||||||
|
/// # 先起后端六容器(patbond-api 仓库根):
|
||||||
|
/// # ./deploy/init-secrets.sh
|
||||||
|
/// # JAVA_HOME=<JDK17> ./mvnw -DskipTests package && docker compose up -d --build
|
||||||
|
/// PATBOND_MEDIA_SMOKE=1 flutter test test/smoke/media_upload_smoke_test.dart
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 驱动**真实 MediaUploader** 走完整链路:注册取 token → createUpload
|
||||||
|
/// (user :8082)→ 预签名 PUT 直传 MinIO(:9000)→ confirm → ready
|
||||||
|
/// assetId → 引用发帖(community :8084)→ 预签名 GET 取回字节一致。
|
||||||
|
/// 压缩层用透传实现(flutter test VM 无原生编解码通道),其余全为生产实现。
|
||||||
|
void main() {
|
||||||
|
final enabled = Platform.environment['PATBOND_MEDIA_SMOKE'] == '1';
|
||||||
|
final env = Platform.environment;
|
||||||
|
final authBase = env['PATBOND_SMOKE_AUTH_BASE'] ?? 'http://127.0.0.1:8081';
|
||||||
|
final userBase = env['PATBOND_SMOKE_USER_BASE'] ?? 'http://127.0.0.1:8082';
|
||||||
|
final communityBase =
|
||||||
|
env['PATBOND_SMOKE_COMMUNITY_BASE'] ?? 'http://127.0.0.1:8084';
|
||||||
|
|
||||||
|
// 1x1 PNG(67 字节,合法图片本体;mime 声明与直传 Content-Type 一致)。
|
||||||
|
final pngBytes = base64Decode(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8'
|
||||||
|
'z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'选图→压缩→createUpload→直传→confirm→ready→引用发帖→GET 回读',
|
||||||
|
() async {
|
||||||
|
// ---- 注册测试账号(一次性,随机凭据,不落任何持久化)----
|
||||||
|
final dio = Dio(BaseOptions(validateStatus: (_) => true));
|
||||||
|
final seed = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final register = await dio.post<Map<String, dynamic>>(
|
||||||
|
'$authBase/api/v1/auth/register',
|
||||||
|
data: {
|
||||||
|
'username': 'smoke$seed',
|
||||||
|
'phone': '+86139${(seed % 100000000).toString().padLeft(8, '0')}',
|
||||||
|
'password': 'Smoke1234!$seed',
|
||||||
|
},
|
||||||
|
options: Options(
|
||||||
|
headers: {
|
||||||
|
'Idempotency-Key': const Uuid().v4(),
|
||||||
|
'X-Device-Id': const Uuid().v4(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(register.data?['code'], 0, reason: '注册失败:${register.data}');
|
||||||
|
|
||||||
|
final session = SessionManager(store: InMemoryTokenStore());
|
||||||
|
await session.updateTokens(
|
||||||
|
AuthTokens.fromJson(register.data!['data'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
final refresher = TokenRefresher(
|
||||||
|
dio: buildPatbondDio(session: session, baseUrl: authBase),
|
||||||
|
session: session,
|
||||||
|
);
|
||||||
|
final repository = ApiCommunityRepository(
|
||||||
|
api: ApiClient(
|
||||||
|
dio: buildPatbondDio(session: session, baseUrl: communityBase),
|
||||||
|
session: session,
|
||||||
|
refresher: refresher,
|
||||||
|
),
|
||||||
|
mediaApi: ApiClient(
|
||||||
|
dio: buildPatbondDio(session: session, baseUrl: userBase),
|
||||||
|
session: session,
|
||||||
|
refresher: refresher,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 真实 MediaUploader 全链路 ----
|
||||||
|
final uploader = MediaUploader(
|
||||||
|
repository: repository,
|
||||||
|
compressor: _PassthroughPngCompressor(),
|
||||||
|
);
|
||||||
|
final done = Completer<void>();
|
||||||
|
uploader.addListener(() {
|
||||||
|
if (done.isCompleted) return;
|
||||||
|
if (uploader.allReady) done.complete();
|
||||||
|
if (uploader.hasFailure) {
|
||||||
|
done.completeError(
|
||||||
|
StateError('上传失败:${uploader.items.single.errorMessage}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
uploader.addImages([
|
||||||
|
PickedMediaImage(bytes: pngBytes, name: 'smoke.png'),
|
||||||
|
]);
|
||||||
|
await done.future.timeout(const Duration(seconds: 60));
|
||||||
|
|
||||||
|
final item = uploader.items.single;
|
||||||
|
expect(item.phase, MediaItemPhase.ready);
|
||||||
|
expect(item.assetId, isNotEmpty);
|
||||||
|
|
||||||
|
// ---- ready assetId 引用发帖(孤儿防护出口)+ 预签名 GET 回读 ----
|
||||||
|
final post = await repository.createPost(
|
||||||
|
CreatePostRequest(
|
||||||
|
content: 'T3-13 媒体上传冒烟 $seed',
|
||||||
|
status: PostStatus.published,
|
||||||
|
media: uploader.buildAttachRequests(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(post.media.single.assetId, item.assetId);
|
||||||
|
final fetched = await dio.get<List<int>>(
|
||||||
|
post.media.single.url,
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
expect(fetched.statusCode, 200);
|
||||||
|
expect(fetched.data, pngBytes, reason: '预签名 GET 回读字节应与上传一致');
|
||||||
|
|
||||||
|
// 收尾:删除冒烟帖(asset 服务端软删语义随帖处理)。
|
||||||
|
await repository.deletePost(post.id);
|
||||||
|
},
|
||||||
|
skip: enabled ? false : '需 compose 后端在位,PATBOND_MEDIA_SMOKE=1 时执行',
|
||||||
|
timeout: const Timeout(Duration(minutes: 3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 透传压缩器:原样输出字节并声明 image/png(仅冒烟用——测试 VM 无
|
||||||
|
/// flutter_image_compress 平台通道;生产走 NativeMediaImageCompressor)。
|
||||||
|
class _PassthroughPngCompressor implements MediaImageCompressor {
|
||||||
|
@override
|
||||||
|
Future<CompressedMediaImage> compress(
|
||||||
|
PickedMediaImage source, {
|
||||||
|
required int quality,
|
||||||
|
}) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png');
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:patbond_flutter/features/auth/session_manager.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'helpers/auth_test_helpers.dart';
|
import 'helpers/auth_test_helpers.dart';
|
||||||
|
import 'helpers/community_test_helpers.dart';
|
||||||
import 'helpers/pet_test_helpers.dart';
|
import 'helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -17,6 +18,8 @@ void main() {
|
|||||||
sessionManager: session,
|
sessionManager: session,
|
||||||
authRepository: FakeAuthRepository(),
|
authRepository: FakeAuthRepository(),
|
||||||
petsRepository: FakePetsRepository(),
|
petsRepository: FakePetsRepository(),
|
||||||
|
communityRepository: FakeCommunityRepository()
|
||||||
|
..onFeed = (_, _) async => feedPage(const []),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/upload_progress_overlay.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
|
||||||
|
/// UploadProgressOverlay(05 号规范 §3.3):排队 / 上传中 / 失败三态
|
||||||
|
/// 形态与失败整格点按重试。
|
||||||
|
void main() {
|
||||||
|
Widget host(Widget overlay) => MaterialApp(
|
||||||
|
home: Scaffold(body: SizedBox(width: 100, height: 100, child: overlay)),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('排队态:scrim + 「等待中」胶囊', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(const UploadProgressOverlay(phase: MediaItemPhase.queued)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('等待中'), findsOneWidget);
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('上传中态:环形进度带 value + 百分比胶囊', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(
|
||||||
|
const UploadProgressOverlay(
|
||||||
|
phase: MediaItemPhase.uploading,
|
||||||
|
progress: 0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final indicator = tester.widget<CircularProgressIndicator>(
|
||||||
|
find.byType(CircularProgressIndicator),
|
||||||
|
);
|
||||||
|
expect(indicator.value, 0.4);
|
||||||
|
expect(find.text('40%'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('confirming 定格 100%', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(
|
||||||
|
const UploadProgressOverlay(
|
||||||
|
phase: MediaItemPhase.confirming,
|
||||||
|
progress: 0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('100%'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('失败态:errorDark 图标 + 重试通栏,整格点按触发 onRetry', (tester) async {
|
||||||
|
var retried = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(
|
||||||
|
UploadProgressOverlay(
|
||||||
|
phase: MediaItemPhase.failed,
|
||||||
|
onRetry: () => retried++,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final icon = tester.widget<Icon>(find.byIcon(Icons.error_outline));
|
||||||
|
expect(icon.color, AppColors.errorDark);
|
||||||
|
expect(find.text('重试'), findsOneWidget);
|
||||||
|
// 整格(含图标区)点按即重试。
|
||||||
|
await tester.tap(find.byIcon(Icons.error_outline));
|
||||||
|
expect(retried, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('失败终态(onRetry null):不显示重试通栏', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(const UploadProgressOverlay(phase: MediaItemPhase.failed)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('重试'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('成功态:150ms 淡出且不拦截点击、无残留文案', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(const UploadProgressOverlay(phase: MediaItemPhase.ready)),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('等待中'), findsNothing);
|
||||||
|
expect(find.text('重试'), findsNothing);
|
||||||
|
expect(find.byType(IgnorePointer), findsWidgets);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,777 @@
|
|||||||
|
#!/usr/bin/env dart
|
||||||
|
|
||||||
|
// ignore_for_file: avoid_print — 手动 E2E 脚本,print 即输出。
|
||||||
|
/// M2 E2E 烟囱测试脚本(T2-18 收官):对 compose 真实后端跑通 M2 完整链路。
|
||||||
|
///
|
||||||
|
/// 前置条件:patbond-api 目录执行 `docker compose up -d`
|
||||||
|
/// 运行方式:dart run test_e2e_m2_manual.dart
|
||||||
|
///
|
||||||
|
/// 覆盖 11 个场景(工单 T2-18 定义的链路):
|
||||||
|
/// 1. 注册账号 A → 登录
|
||||||
|
/// 2. 建档(POST /pets,含品种)→ 列表/详情读回核对
|
||||||
|
/// 3. 记体重 ×2 → 列表分页读回(cursor 分页两页取齐)
|
||||||
|
/// 4. 登记疫苗(scheduled)→ 标记完成(PATCH,version 乐观锁)
|
||||||
|
/// 5. 记健康事件(金额整数分)→ 时间线读回
|
||||||
|
/// 6. 创建提醒 → 标记完成(completedAt 校验)
|
||||||
|
/// 7. 摘要核对:最新体重 / 疫苗进度 / 下次接种 / 当月花费逐项断言
|
||||||
|
/// 8. 权限拒绝:账号 B 访问 A 的宠物四路 → 全部 404/40401 且响应体一致(防枚举)
|
||||||
|
/// 9. 跨设备读取:账号 A 重新登录(新会话)→ 全量数据读回核对
|
||||||
|
/// 10. 埋点链路:POST /api/v1/events 上报 v2 事件 → 202 逐条 accepted
|
||||||
|
/// 11. 乐观锁冲突:两次 PATCH 同一 version → 第二次 409/40902
|
||||||
|
///
|
||||||
|
/// 真机专属项(Android 事件落库观察、SessionTracker 30min 手测)按方案 A 挂起,
|
||||||
|
/// 不在本脚本范围内。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
const authUrl = 'http://127.0.0.1:8081'; // patbond-auth
|
||||||
|
const userUrl = 'http://127.0.0.1:8082'; // patbond-user(/me、/events)
|
||||||
|
const petUrl = 'http://127.0.0.1:8083'; // patbond-pet(pets 域 12 路径)
|
||||||
|
|
||||||
|
final client = HttpClient();
|
||||||
|
int _passed = 0;
|
||||||
|
|
||||||
|
String redact(String token) =>
|
||||||
|
'${token.substring(0, min(20, token.length))}...<REDACTED>';
|
||||||
|
|
||||||
|
void fail(String msg) {
|
||||||
|
print(' ✗ $msg');
|
||||||
|
client.close();
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void check(bool cond, String okMsg, String failMsg) {
|
||||||
|
if (cond) {
|
||||||
|
print(' ✓ $okMsg');
|
||||||
|
} else {
|
||||||
|
fail(failMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Resp {
|
||||||
|
final int status;
|
||||||
|
final String body;
|
||||||
|
final Map<String, dynamic> json;
|
||||||
|
Resp(this.status, this.body, this.json);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Resp> call(
|
||||||
|
String method,
|
||||||
|
String url, {
|
||||||
|
String? token,
|
||||||
|
Object? body,
|
||||||
|
Map<String, String>? headers,
|
||||||
|
}) async {
|
||||||
|
final req = await client.openUrl(method, Uri.parse(url));
|
||||||
|
if (body != null) req.headers.contentType = ContentType.json;
|
||||||
|
if (token != null) req.headers.set('Authorization', 'Bearer $token');
|
||||||
|
headers?.forEach(req.headers.set);
|
||||||
|
if (body != null) req.write(jsonEncode(body));
|
||||||
|
final resp = await req.close();
|
||||||
|
final text = await utf8.decodeStream(resp);
|
||||||
|
Map<String, dynamic> parsed = const {};
|
||||||
|
try {
|
||||||
|
parsed = jsonDecode(text) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
// 非 JSON 响应,parsed 留空 map,由调用方按 status 断言
|
||||||
|
}
|
||||||
|
return Resp(resp.statusCode, text, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
String uuidV4() {
|
||||||
|
final rnd = Random.secure();
|
||||||
|
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
final h = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
return '${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-'
|
||||||
|
'${h.substring(16, 20)}-${h.substring(20)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final usernameA = 'e2e_m2_a_$ts';
|
||||||
|
final usernameB = 'e2e_m2_b_$ts';
|
||||||
|
const password = 'Test@123456';
|
||||||
|
final phoneA = '+8613${Random().nextInt(900000000) + 100000000}';
|
||||||
|
final phoneB = '+8613${Random().nextInt(900000000) + 100000000}';
|
||||||
|
|
||||||
|
print('=== Patbond M2 E2E 烟囱测试开始(T2-18)===');
|
||||||
|
print('账号 A: $usernameA');
|
||||||
|
print('账号 B: $usernameB');
|
||||||
|
print('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ================================================================
|
||||||
|
// [1/11] 注册账号 A → 登录
|
||||||
|
// ================================================================
|
||||||
|
print('[1/11] 注册账号 A → 登录');
|
||||||
|
var r = await call(
|
||||||
|
'POST',
|
||||||
|
'$authUrl/api/v1/auth/register',
|
||||||
|
body: {'username': usernameA, 'phone': phoneA, 'password': password},
|
||||||
|
);
|
||||||
|
print(' POST /api/v1/auth/register → ${r.status}');
|
||||||
|
check(
|
||||||
|
r.status == 200 && r.json['code'] == 0,
|
||||||
|
'注册成功',
|
||||||
|
'注册失败: ${r.status} ${r.body}',
|
||||||
|
);
|
||||||
|
final userIdA = (r.json['data'] as Map)['userId'] as String;
|
||||||
|
print(' userId(A): $userIdA');
|
||||||
|
print(
|
||||||
|
' accessToken: ${redact((r.json['data'] as Map)['accessToken'] as String)}',
|
||||||
|
);
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'POST',
|
||||||
|
'$authUrl/api/v1/auth/login',
|
||||||
|
body: {'username': usernameA, 'password': password},
|
||||||
|
);
|
||||||
|
print(' POST /api/v1/auth/login → ${r.status}');
|
||||||
|
check(
|
||||||
|
r.status == 200 && r.json['code'] == 0,
|
||||||
|
'登录成功(设备 1 会话)',
|
||||||
|
'登录失败: ${r.status} ${r.body}',
|
||||||
|
);
|
||||||
|
var tokenA = (r.json['data'] as Map)['accessToken'] as String;
|
||||||
|
print(' accessToken: ${redact(tokenA)}');
|
||||||
|
_passed++;
|
||||||
|
print('');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// [2/11] 建档(含品种)→ 列表/详情读回核对
|
||||||
|
// ================================================================
|
||||||
|
print('[2/11] 建档(POST /pets,含品种)→ 列表/详情读回核对');
|
||||||
|
r = await call('GET', '$petUrl/api/v1/breeds?species=dog', token: tokenA);
|
||||||
|
print(' GET /api/v1/breeds?species=dog → ${r.status}');
|
||||||
|
check(
|
||||||
|
r.status == 200 && (r.json['data'] as List).isNotEmpty,
|
||||||
|
'品种目录返回 ${(r.json['data'] as List).length} 条',
|
||||||
|
'品种目录读取失败: ${r.body}',
|
||||||
|
);
|
||||||
|
final breed = (r.json['data'] as List).first as Map<String, dynamic>;
|
||||||
|
final breedId = breed['id'] as String;
|
||||||
|
final breedName = breed['displayName'] as String;
|
||||||
|
print(' 选用品种: $breedName ($breedId)');
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'POST',
|
||||||
|
'$petUrl/api/v1/pets',
|
||||||
|
token: tokenA,
|
||||||
|
body: {
|
||||||
|
'name': '旺财M2',
|
||||||
|
'species': 'dog',
|
||||||
|
'breedId': breedId,
|
||||||
|
'sex': 'male',
|
||||||
|
'birthDate': '2024-05-01',
|
||||||
|
'birthDateEstimated': false,
|
||||||
|
'personality': '活泼',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
print(' POST /api/v1/pets → ${r.status}');
|
||||||
|
check(
|
||||||
|
r.status == 201 && r.json['code'] == 0,
|
||||||
|
'建档成功(201)',
|
||||||
|
'建档失败: ${r.status} ${r.body}',
|
||||||
|
);
|
||||||
|
final pet = r.json['data'] as Map<String, dynamic>;
|
||||||
|
final petId = pet['id'] as String;
|
||||||
|
final petVersion = pet['version'] as int;
|
||||||
|
print(' petId: $petId');
|
||||||
|
print(
|
||||||
|
' myRole: ${pet['myRole']} / version: $petVersion / '
|
||||||
|
'breedDisplayName: ${pet['breedDisplayName']}',
|
||||||
|
);
|
||||||
|
check(pet['myRole'] == 'owner', '创建者角色为 owner', 'myRole 非 owner');
|
||||||
|
check(
|
||||||
|
pet['breedDisplayName'] == breedName,
|
||||||
|
'品种展示名解出一致',
|
||||||
|
'breedDisplayName 不一致: ${pet['breedDisplayName']}',
|
||||||
|
);
|
||||||
|
|
||||||
|
r = await call('GET', '$petUrl/api/v1/pets', token: tokenA);
|
||||||
|
print(' GET /api/v1/pets → ${r.status}');
|
||||||
|
final petList = r.json['data'] as List;
|
||||||
|
check(
|
||||||
|
r.status == 200 &&
|
||||||
|
petList.length == 1 &&
|
||||||
|
(petList.first as Map)['id'] == petId,
|
||||||
|
'列表读回 1 只宠物且 id 一致',
|
||||||
|
'列表读回不符: ${r.body}',
|
||||||
|
);
|
||||||
|
|
||||||
|
r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA);
|
||||||
|
print(' GET /api/v1/pets/$petId → ${r.status}');
|
||||||
|
final detail = r.json['data'] as Map<String, dynamic>;
|
||||||
|
check(
|
||||||
|
r.status == 200 &&
|
||||||
|
detail['name'] == '旺财M2' &&
|
||||||
|
detail['species'] == 'dog' &&
|
||||||
|
detail['breedId'] == breedId &&
|
||||||
|
detail['status'] == 'active',
|
||||||
|
'详情读回核对通过(name/species/breedId/status)',
|
||||||
|
'详情读回不符: ${r.body}',
|
||||||
|
);
|
||||||
|
_passed++;
|
||||||
|
print('');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// [3/11] 记体重 ×2 → 列表分页读回
|
||||||
|
// ================================================================
|
||||||
|
print('[3/11] 记体重 ×2 → 列表 cursor 分页读回');
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final measured1 = now.subtract(const Duration(days: 2)).toIso8601String();
|
||||||
|
final measured2 = now.subtract(const Duration(days: 1)).toIso8601String();
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'POST',
|
||||||
|
'$petUrl/api/v1/pets/$petId/weights',
|
||||||
|
token: tokenA,
|
||||||
|
body: {'weightKg': 8.20, 'measuredAt': measured1, 'source': 'manual'},
|
||||||
|
);
|
||||||
|
print(' POST /weights (8.20kg, $measured1) → ${r.status}');
|
||||||
|
check(r.status == 201, '第一条体重创建成功', '体重创建失败: ${r.body}');
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'POST',
|
||||||
|
'$petUrl/api/v1/pets/$petId/weights',
|
||||||
|
token: tokenA,
|
||||||
|
body: {
|
||||||
|
'weightKg': 8.45,
|
||||||
|
'measuredAt': measured2,
|
||||||
|
'source': 'manual',
|
||||||
|
'note': 'M2 烟囱',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
print(' POST /weights (8.45kg, $measured2) → ${r.status}');
|
||||||
|
check(r.status == 201, '第二条体重创建成功', '体重创建失败: ${r.body}');
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'GET',
|
||||||
|
'$petUrl/api/v1/pets/$petId/weights?limit=1',
|
||||||
|
token: tokenA,
|
||||||
|
);
|
||||||
|
print(' GET /weights?limit=1 → ${r.status}');
|
||||||
|
var page = r.json['data'] as Map<String, dynamic>;
|
||||||
|
var items = page['items'] as List;
|
||||||
|
check(
|
||||||
|
r.status == 200 &&
|
||||||
|
items.length == 1 &&
|
||||||
|
(items.first as Map)['weightKg'] == 8.45 &&
|
||||||
|
page['hasMore'] == true &&
|
||||||
|
page['nextCursor'] != null,
|
||||||
|
'第一页:最新体重 8.45kg 在前,hasMore=true,nextCursor 非空',
|
||||||
|
'第一页分页不符: ${r.body}',
|
||||||
|
);
|
||||||
|
final cursor = page['nextCursor'] as String;
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'GET',
|
||||||
|
'$petUrl/api/v1/pets/$petId/weights?limit=1'
|
||||||
|
'&cursor=${Uri.encodeQueryComponent(cursor)}',
|
||||||
|
token: tokenA,
|
||||||
|
);
|
||||||
|
print(' GET /weights?limit=1&cursor=... → ${r.status}');
|
||||||
|
page = r.json['data'] as Map<String, dynamic>;
|
||||||
|
items = page['items'] as List;
|
||||||
|
check(
|
||||||
|
r.status == 200 &&
|
||||||
|
items.length == 1 &&
|
||||||
|
(items.first as Map)['weightKg'] == 8.2 &&
|
||||||
|
page['hasMore'] == false &&
|
||||||
|
page['nextCursor'] == null,
|
||||||
|
'第二页:8.20kg,hasMore=false,nextCursor=null',
|
||||||
|
'第二页分页不符: ${r.body}',
|
||||||
|
);
|
||||||
|
_passed++;
|
||||||
|
print('');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// [4/11] 登记疫苗(scheduled)→ 标记完成(PATCH,乐观锁)
|
||||||
|
// ================================================================
|
||||||
|
print('[4/11] 登记疫苗(scheduled)→ 标记完成(PATCH + version)');
|
||||||
|
r = await call(
|
||||||
|
'GET',
|
||||||
|
'$petUrl/api/v1/vaccine-catalog?species=dog',
|
||||||
|
token: tokenA,
|
||||||
|
);
|
||||||
|
print(' GET /api/v1/vaccine-catalog?species=dog → ${r.status}');
|
||||||
|
check(
|
||||||
|
r.status == 200 && (r.json['data'] as List).isNotEmpty,
|
||||||
|
'疫苗目录返回 ${(r.json['data'] as List).length} 条',
|
||||||
|
'疫苗目录读取失败: ${r.body}',
|
||||||
|
);
|
||||||
|
final vaccine = (r.json['data'] as List).first as Map<String, dynamic>;
|
||||||
|
final vaccineId = vaccine['id'] as String;
|
||||||
|
final vaccineName = vaccine['name'] as String;
|
||||||
|
print(' 选用疫苗: $vaccineName ($vaccineId)');
|
||||||
|
|
||||||
|
final today = now.toIso8601String().substring(0, 10);
|
||||||
|
final nextDue = now
|
||||||
|
.add(const Duration(days: 365))
|
||||||
|
.toIso8601String()
|
||||||
|
.substring(0, 10);
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'POST',
|
||||||
|
'$petUrl/api/v1/pets/$petId/vaccinations',
|
||||||
|
token: tokenA,
|
||||||
|
body: {
|
||||||
|
'vaccineId': vaccineId,
|
||||||
|
'seriesKey': 'primary',
|
||||||
|
'doseNo': 1,
|
||||||
|
'doseLabel': '第一针',
|
||||||
|
'status': 'scheduled',
|
||||||
|
'plannedOn': today,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
print(' POST /vaccinations (scheduled, plannedOn=$today) → ${r.status}');
|
||||||
|
check(
|
||||||
|
r.status == 201 && r.json['code'] == 0,
|
||||||
|
'疫苗登记成功(scheduled)',
|
||||||
|
'疫苗登记失败: ${r.status} ${r.body}',
|
||||||
|
);
|
||||||
|
final vacc = r.json['data'] as Map<String, dynamic>;
|
||||||
|
final vaccinationId = vacc['id'] as String;
|
||||||
|
final vaccVersion = vacc['version'] as int;
|
||||||
|
print(
|
||||||
|
' vaccinationId: $vaccinationId / version: $vaccVersion / '
|
||||||
|
'vaccineName: ${vacc['vaccineName']}',
|
||||||
|
);
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'PATCH',
|
||||||
|
'$petUrl/api/v1/vaccinations/$vaccinationId',
|
||||||
|
token: tokenA,
|
||||||
|
body: {
|
||||||
|
'version': vaccVersion,
|
||||||
|
'status': 'completed',
|
||||||
|
'administeredOn': today,
|
||||||
|
'nextDueOn': nextDue,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
print(
|
||||||
|
' PATCH /vaccinations/$vaccinationId '
|
||||||
|
'(→completed, version=$vaccVersion) → ${r.status}',
|
||||||
|
);
|
||||||
|
final vaccDone = r.json['data'] as Map<String, dynamic>;
|
||||||
|
check(
|
||||||
|
r.status == 200 &&
|
||||||
|
vaccDone['status'] == 'completed' &&
|
||||||
|
vaccDone['administeredOn'] == today &&
|
||||||
|
vaccDone['nextDueOn'] == nextDue &&
|
||||||
|
vaccDone['version'] == vaccVersion + 1,
|
||||||
|
'标记完成成功,version $vaccVersion→${vaccDone['version']},'
|
||||||
|
'administeredOn/nextDueOn 回读一致',
|
||||||
|
'疫苗标记完成不符: ${r.body}',
|
||||||
|
);
|
||||||
|
_passed++;
|
||||||
|
print('');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// [5/11] 记健康事件(金额整数分)→ 时间线读回
|
||||||
|
// ================================================================
|
||||||
|
print('[5/11] 记健康事件(amountCents 整数分)→ 时间线读回');
|
||||||
|
final occurredAt = now.toIso8601String();
|
||||||
|
const amountCents = 12500; // 125.00 元
|
||||||
|
r = await call(
|
||||||
|
'POST',
|
||||||
|
'$petUrl/api/v1/pets/$petId/health-events',
|
||||||
|
token: tokenA,
|
||||||
|
body: {
|
||||||
|
'eventType': 'medical',
|
||||||
|
'occurredAt': occurredAt,
|
||||||
|
'title': 'M2 烟囱体检',
|
||||||
|
'notes': '含金额整数分核对',
|
||||||
|
'amountCents': amountCents,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
print(
|
||||||
|
' POST /health-events (medical, amountCents=$amountCents) '
|
||||||
|
'→ ${r.status}',
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
r.status == 201 && r.json['code'] == 0,
|
||||||
|
'健康事件创建成功',
|
||||||
|
'健康事件创建失败: ${r.status} ${r.body}',
|
||||||
|
);
|
||||||
|
final healthEvent = r.json['data'] as Map<String, dynamic>;
|
||||||
|
final healthEventId = healthEvent['id'] as String;
|
||||||
|
check(
|
||||||
|
healthEvent['amountCents'] == amountCents &&
|
||||||
|
healthEvent['createdByUserId'] == userIdA,
|
||||||
|
'amountCents=$amountCents 原样回读,createdByUserId=token subject',
|
||||||
|
'健康事件字段不符: ${r.body}',
|
||||||
|
);
|
||||||
|
print(' healthEventId: $healthEventId');
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'GET',
|
||||||
|
'$petUrl/api/v1/pets/$petId/health-events',
|
||||||
|
token: tokenA,
|
||||||
|
);
|
||||||
|
print(' GET /health-events → ${r.status}');
|
||||||
|
final timeline = (r.json['data'] as Map)['items'] as List;
|
||||||
|
check(
|
||||||
|
r.status == 200 &&
|
||||||
|
timeline.length == 1 &&
|
||||||
|
(timeline.first as Map)['id'] == healthEventId &&
|
||||||
|
(timeline.first as Map)['title'] == 'M2 烟囱体检',
|
||||||
|
'时间线读回 1 条且字段一致',
|
||||||
|
'时间线读回不符: ${r.body}',
|
||||||
|
);
|
||||||
|
_passed++;
|
||||||
|
print('');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// [6/11] 创建提醒 → 标记完成(completedAt 校验)
|
||||||
|
// ================================================================
|
||||||
|
print('[6/11] 创建提醒 → 标记完成(completedAt 校验)');
|
||||||
|
final dueAt = now.add(const Duration(days: 30)).toIso8601String();
|
||||||
|
r = await call(
|
||||||
|
'POST',
|
||||||
|
'$petUrl/api/v1/pets/$petId/care-reminders',
|
||||||
|
token: tokenA,
|
||||||
|
body: {'reminderType': 'deworming', 'title': '季度驱虫', 'dueAt': dueAt},
|
||||||
|
);
|
||||||
|
print(' POST /care-reminders (deworming, dueAt=$dueAt) → ${r.status}');
|
||||||
|
final reminder = r.json['data'] as Map<String, dynamic>;
|
||||||
|
check(
|
||||||
|
r.status == 201 &&
|
||||||
|
reminder['status'] == 'pending' &&
|
||||||
|
reminder['completedAt'] == null,
|
||||||
|
'提醒创建成功,恒为 pending 且 completedAt=null',
|
||||||
|
'提醒创建不符: ${r.status} ${r.body}',
|
||||||
|
);
|
||||||
|
final reminderId = reminder['id'] as String;
|
||||||
|
print(' reminderId: $reminderId');
|
||||||
|
|
||||||
|
final completedAt = now.toIso8601String();
|
||||||
|
r = await call(
|
||||||
|
'PATCH',
|
||||||
|
'$petUrl/api/v1/care-reminders/$reminderId',
|
||||||
|
token: tokenA,
|
||||||
|
body: {'status': 'completed', 'completedAt': completedAt},
|
||||||
|
);
|
||||||
|
print(' PATCH /care-reminders/$reminderId (→completed) → ${r.status}');
|
||||||
|
final reminderDone = r.json['data'] as Map<String, dynamic>;
|
||||||
|
check(
|
||||||
|
r.status == 200 &&
|
||||||
|
reminderDone['status'] == 'completed' &&
|
||||||
|
reminderDone['completedAt'] != null,
|
||||||
|
'标记完成成功,completedAt=${reminderDone['completedAt']}(客户端提交时刻回读)',
|
||||||
|
'提醒标记完成不符: ${r.body}',
|
||||||
|
);
|
||||||
|
_passed++;
|
||||||
|
print('');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// [7/11] 摘要核对:四项聚合逐项断言
|
||||||
|
// ================================================================
|
||||||
|
print('[7/11] GET /summary?tz=Asia/Shanghai 四项聚合逐项断言');
|
||||||
|
r = await call(
|
||||||
|
'GET',
|
||||||
|
'$petUrl/api/v1/pets/$petId/summary?tz=Asia/Shanghai',
|
||||||
|
token: tokenA,
|
||||||
|
);
|
||||||
|
print(' GET /summary → ${r.status}');
|
||||||
|
check(
|
||||||
|
r.status == 200 && r.json['code'] == 0,
|
||||||
|
'摘要返回 200',
|
||||||
|
'摘要读取失败: ${r.status} ${r.body}',
|
||||||
|
);
|
||||||
|
final summary = r.json['data'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final latestWeight = summary['latestWeight'] as Map<String, dynamic>?;
|
||||||
|
check(
|
||||||
|
latestWeight != null && latestWeight['weightKg'] == 8.45,
|
||||||
|
'最新体重 = 8.45kg(第二条写入,measured_at DESC 首行)',
|
||||||
|
'latestWeight 不符: $latestWeight',
|
||||||
|
);
|
||||||
|
|
||||||
|
final progress = summary['vaccinationProgress'] as Map<String, dynamic>?;
|
||||||
|
check(
|
||||||
|
progress != null &&
|
||||||
|
progress['completedDoses'] == 1 &&
|
||||||
|
progress['totalDoses'] == 1,
|
||||||
|
'疫苗进度 = 1/1(scheduled→completed 后)',
|
||||||
|
'vaccinationProgress 不符: $progress',
|
||||||
|
);
|
||||||
|
|
||||||
|
final nextVacc = summary['nextVaccination'] as Map<String, dynamic>?;
|
||||||
|
check(
|
||||||
|
nextVacc != null &&
|
||||||
|
nextVacc['vaccinationId'] == vaccinationId &&
|
||||||
|
nextVacc['dueOn'] == nextDue &&
|
||||||
|
nextVacc['source'] == 'nextDue',
|
||||||
|
'下次接种 = completed 行的 nextDueOn($nextDue,source=nextDue)',
|
||||||
|
'nextVaccination 不符: $nextVacc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final expense = summary['monthlyExpense'] as Map<String, dynamic>;
|
||||||
|
final shanghaiNow = now.add(const Duration(hours: 8));
|
||||||
|
final expectMonth =
|
||||||
|
'${shanghaiNow.year}-'
|
||||||
|
'${shanghaiNow.month.toString().padLeft(2, '0')}';
|
||||||
|
check(
|
||||||
|
expense['amountCents'] == amountCents &&
|
||||||
|
expense['month'] == expectMonth &&
|
||||||
|
expense['timezone'] == 'Asia/Shanghai',
|
||||||
|
'当月花费 = $amountCents 分,month=$expectMonth,timezone 回显 Asia/Shanghai',
|
||||||
|
'monthlyExpense 不符: $expense',
|
||||||
|
);
|
||||||
|
_passed++;
|
||||||
|
print('');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// [8/11] 权限拒绝:账号 B 访问 A 的宠物四路 → 404/40401 响应体一致
|
||||||
|
// ================================================================
|
||||||
|
print('[8/11] 注册账号 B → 用 B 的 token 访问 A 的宠物四路(防枚举核对)');
|
||||||
|
r = await call(
|
||||||
|
'POST',
|
||||||
|
'$authUrl/api/v1/auth/register',
|
||||||
|
body: {'username': usernameB, 'phone': phoneB, 'password': password},
|
||||||
|
);
|
||||||
|
print(' POST /api/v1/auth/register (B) → ${r.status}');
|
||||||
|
check(
|
||||||
|
r.status == 200 && r.json['code'] == 0,
|
||||||
|
'账号 B 注册成功',
|
||||||
|
'B 注册失败: ${r.body}',
|
||||||
|
);
|
||||||
|
final tokenB = (r.json['data'] as Map)['accessToken'] as String;
|
||||||
|
print(' accessToken(B): ${redact(tokenB)}');
|
||||||
|
|
||||||
|
final deniedRoutes = <String, String>{
|
||||||
|
'详情 GET /pets/{id}': '$petUrl/api/v1/pets/$petId',
|
||||||
|
'体重 GET /pets/{id}/weights': '$petUrl/api/v1/pets/$petId/weights',
|
||||||
|
'疫苗 GET /pets/{id}/vaccinations':
|
||||||
|
'$petUrl/api/v1/pets/$petId/vaccinations',
|
||||||
|
'摘要 GET /pets/{id}/summary': '$petUrl/api/v1/pets/$petId/summary',
|
||||||
|
};
|
||||||
|
final deniedBodies = <String>[];
|
||||||
|
for (final entry in deniedRoutes.entries) {
|
||||||
|
r = await call('GET', entry.value, token: tokenB);
|
||||||
|
print(' ${entry.key} → ${r.status} / code ${r.json['code']}');
|
||||||
|
check(
|
||||||
|
r.status == 404 && r.json['code'] == 40401,
|
||||||
|
'404/40401(${entry.key})',
|
||||||
|
'${entry.key} 未按防枚举拒绝: ${r.body}',
|
||||||
|
);
|
||||||
|
deniedBodies.add(r.body);
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
deniedBodies.toSet().length == 1,
|
||||||
|
'四路响应体完全一致(防枚举):${deniedBodies.first}',
|
||||||
|
'四路响应体不一致: $deniedBodies',
|
||||||
|
);
|
||||||
|
|
||||||
|
r = await call('GET', '$petUrl/api/v1/pets', token: tokenB);
|
||||||
|
check(
|
||||||
|
r.status == 200 && (r.json['data'] as List).isEmpty,
|
||||||
|
'B 的宠物列表为空(列表天然隔离)',
|
||||||
|
'B 列表泄露: ${r.body}',
|
||||||
|
);
|
||||||
|
_passed++;
|
||||||
|
print('');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// [9/11] 跨设备读取:账号 A 重新登录(新会话)→ 全量数据读回
|
||||||
|
// ================================================================
|
||||||
|
print('[9/11] 账号 A 重新登录(模拟第二设备新会话)→ 全量数据读回');
|
||||||
|
r = await call(
|
||||||
|
'POST',
|
||||||
|
'$authUrl/api/v1/auth/login',
|
||||||
|
body: {'username': usernameA, 'password': password},
|
||||||
|
);
|
||||||
|
print(' POST /api/v1/auth/login (设备 2) → ${r.status}');
|
||||||
|
check(r.status == 200, '第二设备登录成功', '第二设备登录失败: ${r.body}');
|
||||||
|
final tokenA2 = (r.json['data'] as Map)['accessToken'] as String;
|
||||||
|
check(
|
||||||
|
tokenA2 != tokenA,
|
||||||
|
'新会话 token 与设备 1 不同(独立 token family)',
|
||||||
|
'两次登录 token 相同',
|
||||||
|
);
|
||||||
|
print(' accessToken(设备2): ${redact(tokenA2)}');
|
||||||
|
|
||||||
|
r = await call('GET', '$petUrl/api/v1/pets', token: tokenA2);
|
||||||
|
check(
|
||||||
|
r.status == 200 &&
|
||||||
|
(r.json['data'] as List).length == 1 &&
|
||||||
|
((r.json['data'] as List).first as Map)['name'] == '旺财M2',
|
||||||
|
'宠物列表:1 只(旺财M2)',
|
||||||
|
'设备 2 宠物列表不符: ${r.body}',
|
||||||
|
);
|
||||||
|
|
||||||
|
r = await call('GET', '$petUrl/api/v1/pets/$petId/weights', token: tokenA2);
|
||||||
|
check(
|
||||||
|
((r.json['data'] as Map)['items'] as List).length == 2,
|
||||||
|
'体重记录:2 条',
|
||||||
|
'设备 2 体重不符: ${r.body}',
|
||||||
|
);
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'GET',
|
||||||
|
'$petUrl/api/v1/pets/$petId/vaccinations',
|
||||||
|
token: tokenA2,
|
||||||
|
);
|
||||||
|
final vaccList = r.json['data'] as List;
|
||||||
|
check(
|
||||||
|
vaccList.length == 1 && (vaccList.first as Map)['status'] == 'completed',
|
||||||
|
'疫苗记录:1 条(completed)',
|
||||||
|
'设备 2 疫苗不符: ${r.body}',
|
||||||
|
);
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'GET',
|
||||||
|
'$petUrl/api/v1/pets/$petId/health-events',
|
||||||
|
token: tokenA2,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
((r.json['data'] as Map)['items'] as List).length == 1,
|
||||||
|
'健康事件:1 条',
|
||||||
|
'设备 2 健康事件不符: ${r.body}',
|
||||||
|
);
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'GET',
|
||||||
|
'$petUrl/api/v1/pets/$petId/care-reminders',
|
||||||
|
token: tokenA2,
|
||||||
|
);
|
||||||
|
final remList = r.json['data'] as List;
|
||||||
|
check(
|
||||||
|
remList.length == 1 && (remList.first as Map)['status'] == 'completed',
|
||||||
|
'提醒:1 条(completed,completedAt=${(remList.first as Map)['completedAt']})',
|
||||||
|
'设备 2 提醒不符: ${r.body}',
|
||||||
|
);
|
||||||
|
_passed++;
|
||||||
|
print('');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// [10/11] 埋点链路:POST /api/v1/events 上报 v2 事件 → 202 逐条 accepted
|
||||||
|
// ================================================================
|
||||||
|
print('[10/11] POST /api/v1/events 上报 v2 事件(platform=android 模拟真机值)');
|
||||||
|
final anonymousId = uuidV4();
|
||||||
|
final sessionId = uuidV4();
|
||||||
|
final clientTs = DateTime.now().toUtc().toIso8601String();
|
||||||
|
Map<String, dynamic> baseEvent(String name, Map<String, dynamic> props) => {
|
||||||
|
'eventId': uuidV4(),
|
||||||
|
'eventName': name,
|
||||||
|
'eventVersion': 2,
|
||||||
|
'anonymousId': anonymousId,
|
||||||
|
'userId': userIdA,
|
||||||
|
'sessionId': sessionId,
|
||||||
|
'clientTs': clientTs,
|
||||||
|
'appVersion': '1.0.0+e2e',
|
||||||
|
'platform': 'android',
|
||||||
|
'osVersion': 'android-14',
|
||||||
|
'props': props,
|
||||||
|
};
|
||||||
|
final events = [
|
||||||
|
baseEvent('pet_create_succeeded', {
|
||||||
|
'durationMs': 1200,
|
||||||
|
'species': 'dog',
|
||||||
|
'petIndex': 1,
|
||||||
|
}),
|
||||||
|
baseEvent('health_record_create_succeeded', {
|
||||||
|
'recordType': 'weight',
|
||||||
|
'durationMs': 640,
|
||||||
|
}),
|
||||||
|
baseEvent('health_record_create_succeeded', {
|
||||||
|
'recordType': 'vaccine',
|
||||||
|
'durationMs': 820,
|
||||||
|
}),
|
||||||
|
baseEvent('page_viewed', {
|
||||||
|
'pageName': 'pet_detail',
|
||||||
|
'referrer': 'pet_list',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
for (final e in events) {
|
||||||
|
print(' eventId: ${e['eventId']} (${e['eventName']})');
|
||||||
|
}
|
||||||
|
r = await call(
|
||||||
|
'POST',
|
||||||
|
'$userUrl/api/v1/events',
|
||||||
|
token: tokenA2,
|
||||||
|
body: {'events': events},
|
||||||
|
);
|
||||||
|
print(' POST /api/v1/events (4 条) → ${r.status}');
|
||||||
|
check(
|
||||||
|
r.status == 202 && r.json['code'] == 0,
|
||||||
|
'批次受理 202',
|
||||||
|
'埋点上报失败: ${r.status} ${r.body}',
|
||||||
|
);
|
||||||
|
final trackData = r.json['data'] as Map<String, dynamic>;
|
||||||
|
final results = trackData['results'] as List;
|
||||||
|
final allAccepted = results.every(
|
||||||
|
(e) => (e as Map)['status'] == 'accepted',
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
trackData['accepted'] == 4 &&
|
||||||
|
trackData['rejected'] == 0 &&
|
||||||
|
results.length == 4 &&
|
||||||
|
allAccepted,
|
||||||
|
'4/4 逐条 accepted(accepted=4, duplicated=0, rejected=0)',
|
||||||
|
'埋点结果不符: ${r.body}',
|
||||||
|
);
|
||||||
|
print(' 落库核对(platform.product_events)由报告附 psql 证据。');
|
||||||
|
print(' E2E_SESSION_ID=$sessionId'); // 供 psql 查证
|
||||||
|
_passed++;
|
||||||
|
print('');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// [11/11] 乐观锁冲突明确性:两次 PATCH 同一 version → 40902
|
||||||
|
// ================================================================
|
||||||
|
print('[11/11] 两次 PATCH 宠物档案提交同一 version → 第二次 409/40902');
|
||||||
|
r = await call(
|
||||||
|
'PATCH',
|
||||||
|
'$petUrl/api/v1/pets/$petId',
|
||||||
|
token: tokenA,
|
||||||
|
body: {'version': petVersion, 'personality': '沉稳'},
|
||||||
|
);
|
||||||
|
print(' PATCH /pets/$petId (version=$petVersion, 第一次) → ${r.status}');
|
||||||
|
check(
|
||||||
|
r.status == 200 && (r.json['data'] as Map)['version'] == petVersion + 1,
|
||||||
|
'第一次 PATCH 成功,version $petVersion→${petVersion + 1}',
|
||||||
|
'第一次 PATCH 失败: ${r.body}',
|
||||||
|
);
|
||||||
|
|
||||||
|
r = await call(
|
||||||
|
'PATCH',
|
||||||
|
'$petUrl/api/v1/pets/$petId',
|
||||||
|
token: tokenA2,
|
||||||
|
body: {'version': petVersion, 'personality': '黏人'},
|
||||||
|
);
|
||||||
|
print(
|
||||||
|
' PATCH /pets/$petId (同一过期 version=$petVersion, 第二次/设备 2) '
|
||||||
|
'→ ${r.status}',
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
r.status == 409 && r.json['code'] == 40902,
|
||||||
|
'第二次被明确拒绝:409/40902(${r.json['message']}),先写者数据保留',
|
||||||
|
'乐观锁冲突语义不符: ${r.status} ${r.body}',
|
||||||
|
);
|
||||||
|
|
||||||
|
r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA);
|
||||||
|
check(
|
||||||
|
(r.json['data'] as Map)['personality'] == '沉稳',
|
||||||
|
'读回确认先写者数据保留(personality=沉稳)',
|
||||||
|
'并发覆盖发生: ${r.body}',
|
||||||
|
);
|
||||||
|
_passed++;
|
||||||
|
print('');
|
||||||
|
|
||||||
|
print('=== M2 E2E 烟囱测试全部通过 ✓($_passed/11 场景)===');
|
||||||
|
print('E2E_USERNAME_A=$usernameA');
|
||||||
|
print('E2E_PET_ID=$petId');
|
||||||
|
} catch (e, stack) {
|
||||||
|
print('✗ 测试异常: $e');
|
||||||
|
print(stack);
|
||||||
|
exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,12 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_windows
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user