Compare commits
10 Commits
ba503327f5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e87413360 | |||
| 9892b65a19 | |||
| f873acf9a3 | |||
| 92524da8e2 | |||
| 8aac8c52cc | |||
| 1441f0148f | |||
| 19bd8c1810 | |||
| 66f983d680 | |||
| 4d40c38f06 | |||
| 720865bcb9 |
@@ -26,6 +26,10 @@ jobs:
|
||||
git remote add origin "$AUTH_URL/${{ github.repository }}.git"
|
||||
git fetch -q --depth 1 origin "+${{ github.ref }}: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)
|
||||
run: |
|
||||
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'),
|
||||
petArchive('pet_archive'),
|
||||
services('services'),
|
||||
postDetail('post_detail');
|
||||
postDetail('post_detail'),
|
||||
// —— 字典 v3 页面族(22 号报告 §2;本枚举登记 M3 已落地页)——
|
||||
/// 发布页(P3,T3-17 push 全屏页)。
|
||||
postForm('post_form');
|
||||
|
||||
const AnalyticsPageName(this.pageName);
|
||||
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Analytics client for report 13: track events to backend POST
|
||||
/// /api/v1/events. Events land in a segmented persistent queue
|
||||
/// ([AnalyticsEventStore], shared_preferences, cap 500 oldest-dropped),
|
||||
/// flushed every 20 events and on leaving foreground; cold start [restore]
|
||||
/// re-uploads offline backlog. Privacy red-line enforced locally.
|
||||
/// flushed every 20 events, every [flushInterval] while foregrounded
|
||||
/// ([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 {
|
||||
AnalyticsService({
|
||||
required this.apiBaseUrl,
|
||||
@@ -16,10 +21,14 @@ class AnalyticsService {
|
||||
required this.getSessionId,
|
||||
String? anonymousId,
|
||||
AnalyticsEventStore? store,
|
||||
this.flushInterval = const Duration(seconds: 30),
|
||||
DateTime Function()? now,
|
||||
}) : _anonymousId = anonymousId ?? const Uuid().v4(),
|
||||
_anonymousIdInjected = anonymousId != null,
|
||||
_appVersion = 'unknown',
|
||||
_osVersion = _defaultOsVersion(),
|
||||
_store = store ?? AnalyticsEventStore();
|
||||
_store = store ?? AnalyticsEventStore(),
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
/// 异步设置 appVersion(app.dart 启动时从 package_info_plus 读取后注入)。
|
||||
void setAppVersion(String version) {
|
||||
@@ -32,23 +41,49 @@ class AnalyticsService {
|
||||
// 契约单批上限(13 号规范 §1.1:单批 1–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? Function()? getAccessToken;
|
||||
|
||||
/// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。
|
||||
final String Function() getSessionId;
|
||||
|
||||
final String _anonymousId;
|
||||
/// 前台定时冲刷周期(13 号 §3.4 第 4 触发点),构造参数化便于测试注入。
|
||||
final Duration flushInterval;
|
||||
|
||||
String _anonymousId;
|
||||
|
||||
/// 构造显式注入 anonymousId 的测试通道不参与持久化采用/落盘。
|
||||
final bool _anonymousIdInjected;
|
||||
String _appVersion;
|
||||
final String _osVersion;
|
||||
String? _userId;
|
||||
bool _flushing = false;
|
||||
final AnalyticsEventStore _store;
|
||||
|
||||
/// 时钟注入口(照 SessionTracker 先例),退避判定测试免真实等待。
|
||||
final DateTime Function() _now;
|
||||
|
||||
Timer? _flushTimer;
|
||||
Duration? _backoffDelay;
|
||||
DateTime? _retryNotBefore;
|
||||
|
||||
/// 待上报事件(测试断言用,生产代码不得直接操作)。
|
||||
@visibleForTesting
|
||||
List<Map<String, dynamic>> get pendingEvents => _store.events;
|
||||
|
||||
/// 当前匿名标识(测试断言用)。
|
||||
@visibleForTesting
|
||||
String get anonymousId => _anonymousId;
|
||||
|
||||
/// 粗粒度 osVersion(13 号规范 §4.0:主版本级,如 android-14)。
|
||||
/// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。
|
||||
static String _defaultOsVersion() {
|
||||
@@ -121,10 +156,12 @@ class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 冷启动恢复持久化队列(离线积压约两周容量),有积压即冲刷一次
|
||||
/// (13 号规范 §3.4 冷启动触发)。app 启动时调用,不阻塞渲染。
|
||||
/// 冷启动恢复:先采用持久化 anonymousId,再恢复持久化队列(离线积压
|
||||
/// 约两周容量),有积压即冲刷一次(13 号规范 §3.4 冷启动触发)。
|
||||
/// app 启动时调用,不阻塞渲染。
|
||||
Future<void> restore() async {
|
||||
try {
|
||||
await _restoreAnonymousId();
|
||||
await _store.restore();
|
||||
if (_store.length > 0) {
|
||||
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] 条导致事件永不上传)。
|
||||
Future<void> flushNow() => _flush();
|
||||
@@ -147,22 +221,40 @@ class AnalyticsService {
|
||||
// 取段拼批(入选段即封段,冲刷中的新事件写入新开放段不会丢)。
|
||||
final batch = _store.takeBatch(_maxBatchEvents);
|
||||
if (batch.isEmpty) break;
|
||||
final rejected = await _upload(batch.events);
|
||||
final rejected = await uploadBatch(batch.events);
|
||||
// 拿到服务端应答即连通性恢复,重置退避。
|
||||
_resetBackoff();
|
||||
// at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段;
|
||||
// 4xx 批次计入本地丢弃诊断数。
|
||||
await _store.removeSegments(batch.segmentIds, countAsDropped: rejected);
|
||||
}
|
||||
} catch (error) {
|
||||
// 网络错误 / 5xx:段保留在持久化队列,等下次触发或冷启动重传。
|
||||
// 网络错误 / 5xx:段保留在持久化队列,指数退避后由定时冲刷重试,
|
||||
// 或等显式触发/冷启动重传。
|
||||
_scheduleBackoff();
|
||||
debugPrint('Analytics upload failed, events kept queued: $error');
|
||||
} finally {
|
||||
_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 永久拒绝(调用方删段并计丢弃);
|
||||
/// 网络错误 / 5xx 抛异常(调用方保留段)。
|
||||
Future<bool> _upload(List<Map<String, dynamic>> events) async {
|
||||
/// 网络错误 / 5xx / 429 抛异常(调用方保留段并退避)。
|
||||
/// protected:测试子类以假上传替换,免起真实 HttpServer。
|
||||
@protected
|
||||
@visibleForTesting
|
||||
Future<bool> uploadBatch(List<Map<String, dynamic>> events) async {
|
||||
final token = getAccessToken?.call();
|
||||
final request =
|
||||
await HttpClient().postUrl(Uri.parse('$apiBaseUrl/api/v1/events'))
|
||||
@@ -173,6 +265,11 @@ class AnalyticsService {
|
||||
request.add(utf8.encode(jsonEncode({'events': events})));
|
||||
|
||||
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) {
|
||||
// 4xx 为永久性拒绝(校验失败/批量超限等),重试不可能成功;
|
||||
// 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。
|
||||
|
||||
@@ -15,6 +15,7 @@ class SessionTracker with WidgetsBindingObserver {
|
||||
SessionTracker({
|
||||
this.timeout = const Duration(minutes: 30),
|
||||
this.onLeaveForeground,
|
||||
this.onEnterForeground,
|
||||
DateTime Function()? now,
|
||||
}) : _now = now ?? DateTime.now,
|
||||
_sessionId = const Uuid().v7();
|
||||
@@ -25,6 +26,10 @@ class SessionTracker with WidgetsBindingObserver {
|
||||
/// 首次离开前台时回调(app 装配层用于触发埋点队列冲刷)。
|
||||
final VoidCallback? onLeaveForeground;
|
||||
|
||||
/// 回到前台时回调(app 装配层用于恢复 30 秒定时冲刷);
|
||||
/// 冷启动首个 resumed 不触发(此前未离开过前台)。
|
||||
final VoidCallback? onEnterForeground;
|
||||
|
||||
/// 时钟注入口,测试免真实等待。
|
||||
final DateTime Function() _now;
|
||||
|
||||
@@ -39,9 +44,12 @@ class SessionTracker with WidgetsBindingObserver {
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
final leftAt = _leftForegroundAt;
|
||||
if (leftAt != null && _now().difference(leftAt) > timeout) {
|
||||
if (leftAt != null) {
|
||||
if (_now().difference(leftAt) > timeout) {
|
||||
_sessionId = const Uuid().v7();
|
||||
}
|
||||
onEnterForeground?.call();
|
||||
}
|
||||
_leftForegroundAt = null;
|
||||
} else if (_lastState == AppLifecycleState.resumed) {
|
||||
// 首次离开前台才记时;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/session_manager.dart';
|
||||
import 'package:patbond_flutter/features/auth/splash_page.dart';
|
||||
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
|
||||
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||
import 'package:patbond_flutter/features/community/post_analytics.dart';
|
||||
import 'package:patbond_flutter/features/main/main_shell_page.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||
@@ -25,12 +31,18 @@ class App extends StatefulWidget {
|
||||
this.sessionManager,
|
||||
this.authRepository,
|
||||
this.petsRepository,
|
||||
this.communityRepository,
|
||||
this.mediaUploaderFactory,
|
||||
});
|
||||
|
||||
/// 测试注入口;生产默认走安全存储 + 真实 API。
|
||||
final SessionManager? sessionManager;
|
||||
final AuthRepository? authRepository;
|
||||
final PetsRepository? petsRepository;
|
||||
final CommunityRepository? communityRepository;
|
||||
|
||||
/// 发布页媒体上传器构造口(桌面实测替换选图/压缩层;生产为 null)。
|
||||
final MediaUploaderFactory? mediaUploaderFactory;
|
||||
|
||||
@override
|
||||
State<App> createState() => _AppState();
|
||||
@@ -41,8 +53,12 @@ class _AppState extends State<App> {
|
||||
late final SessionManager sessionManager;
|
||||
late final AuthRepository authRepository;
|
||||
late final PetsController petsController;
|
||||
late final CommunityController communityController;
|
||||
late final PetAnalytics petAnalytics;
|
||||
late final HealthRecordAnalytics healthRecordAnalytics;
|
||||
late final FeedAnalytics feedAnalytics;
|
||||
late final CommunityInteractionAnalytics interactionAnalytics;
|
||||
late final PostAnalytics postAnalytics;
|
||||
late final SessionTracker _sessionTracker;
|
||||
late final AnalyticsService _analytics;
|
||||
late final PageViewTracker _pageViewTracker;
|
||||
@@ -58,7 +74,12 @@ class _AppState extends State<App> {
|
||||
SessionManager(store: const SecureTokenStore());
|
||||
|
||||
_sessionTracker = SessionTracker(
|
||||
onLeaveForeground: () => _analytics.flushNow(),
|
||||
onLeaveForeground: () {
|
||||
// 退后台:停定时冲刷并立即冲刷一次(既有触发点保留)。
|
||||
_analytics.stopPeriodicFlush();
|
||||
_analytics.flushNow();
|
||||
},
|
||||
onEnterForeground: () => _analytics.startPeriodicFlush(),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(_sessionTracker);
|
||||
|
||||
@@ -70,6 +91,8 @@ class _AppState extends State<App> {
|
||||
_initAppVersion();
|
||||
// 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。
|
||||
_analytics.restore();
|
||||
// 前台期间 30 秒定时冲刷(13 号 §3.4 第 4 触发点)。
|
||||
_analytics.startPeriodicFlush();
|
||||
|
||||
_pageViewTracker = PageViewTracker(_analytics.trackEvent);
|
||||
_routeObserver = AnalyticsRouteObserver(
|
||||
@@ -81,8 +104,18 @@ class _AppState extends State<App> {
|
||||
petsController = PetsController(
|
||||
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);
|
||||
healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent);
|
||||
feedAnalytics = FeedAnalytics(_analytics.trackEvent);
|
||||
// T3-17:发布漏斗五事件 + 媒体上传三段(发布页与 MediaUploader 消费)。
|
||||
postAnalytics = PostAnalytics(_analytics.trackEvent);
|
||||
|
||||
// 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件)
|
||||
sessionManager.addListener(_reportAuthStateChange);
|
||||
@@ -136,6 +169,31 @@ class _AppState extends State<App> {
|
||||
return ApiPetsRepository(api: api);
|
||||
}
|
||||
|
||||
/// community 服务分端口直连(:8084);media 两步上传端点由 user 服务
|
||||
/// (:8082 MediaController)提供,单独建 mediaApi 直连。token 刷新
|
||||
/// 单飞经共享 [TokenRefresher]。
|
||||
CommunityRepository _buildCommunityRepository() {
|
||||
final dio = buildPatbondDio(
|
||||
session: sessionManager,
|
||||
baseUrl: patbondCommunityApiBaseUrl,
|
||||
);
|
||||
final api = ApiClient(
|
||||
dio: dio,
|
||||
session: sessionManager,
|
||||
refresher: _ensureRefresher(),
|
||||
);
|
||||
final mediaDio = buildPatbondDio(
|
||||
session: sessionManager,
|
||||
baseUrl: patbondUserApiBaseUrl,
|
||||
);
|
||||
final mediaApi = ApiClient(
|
||||
dio: mediaDio,
|
||||
session: sessionManager,
|
||||
refresher: _ensureRefresher(),
|
||||
);
|
||||
return ApiCommunityRepository(api: api, mediaApi: mediaApi);
|
||||
}
|
||||
|
||||
AnalyticsPageName? _resolveRootPage() {
|
||||
// 回栈到无名根路由时解析当前认证状态页/主壳 Tab
|
||||
return switch (sessionManager.status) {
|
||||
@@ -146,9 +204,10 @@ class _AppState extends State<App> {
|
||||
}
|
||||
|
||||
void _reportAuthStateChange() {
|
||||
// 登出即清宠物档案内存副本(跨账号不泄漏;重登后列表页重新拉取)。
|
||||
// 登出即清宠物档案与社区 Feed 内存副本(跨账号不泄漏;重登后重新拉取)。
|
||||
if (sessionManager.status == AuthStatus.unauthenticated) {
|
||||
petsController.reset();
|
||||
communityController.reset();
|
||||
}
|
||||
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
|
||||
final page = switch (sessionManager.status) {
|
||||
@@ -161,10 +220,12 @@ class _AppState extends State<App> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_analytics.stopPeriodicFlush();
|
||||
sessionManager.removeListener(_reportAuthStateChange);
|
||||
WidgetsBinding.instance.removeObserver(_sessionTracker);
|
||||
appState.dispose();
|
||||
petsController.dispose();
|
||||
communityController.dispose();
|
||||
if (widget.sessionManager == null) sessionManager.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -187,8 +248,14 @@ class _AppState extends State<App> {
|
||||
key: const ValueKey('shell'),
|
||||
appState: appState,
|
||||
petsController: petsController,
|
||||
communityController: communityController,
|
||||
currentUserId: sessionManager.userId,
|
||||
petAnalytics: petAnalytics,
|
||||
healthRecordAnalytics: healthRecordAnalytics,
|
||||
feedAnalytics: feedAnalytics,
|
||||
interactionAnalytics: interactionAnalytics,
|
||||
postAnalytics: postAnalytics,
|
||||
mediaUploaderFactory: widget.mediaUploaderFactory,
|
||||
pageViewTracker: _pageViewTracker,
|
||||
onLogout: authRepository.logout,
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
/// 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 实例。
|
||||
///
|
||||
/// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成
|
||||
|
||||
@@ -48,6 +48,36 @@ abstract final class ApiCodes {
|
||||
|
||||
/// 提醒状态机 / completed-completedAt 一致性违反(HTTP 422)。
|
||||
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 调用的类型化异常。页面按类型映射为三层错误呈现
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
/// 创作 Tab。
|
||||
///
|
||||
/// **AI 生成模拟(700/650/500ms 假延时、风格/模型/分辨率设置、结果卡)
|
||||
/// 属 M4 范围,T3-17 原样保留**;社区发布半边自 T3-17 起改由真实发布页
|
||||
/// (`PostComposePage`,push 全屏)承担——本页顶部「发布动态」入口即其
|
||||
/// 入口(entryPoint=create_tab),`AppState.publishPost` demo 发布流退役。
|
||||
class CreatePage extends StatefulWidget {
|
||||
const CreatePage({
|
||||
required this.appState,
|
||||
required this.onPublished,
|
||||
required this.onOpenCompose,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppState appState;
|
||||
final ValueChanged<PostModel> onPublished;
|
||||
|
||||
/// 真实发布页入口(主壳 push,发布成功后回首页 Feed 刷新)。
|
||||
final VoidCallback onOpenCompose;
|
||||
|
||||
@override
|
||||
State<CreatePage> createState() => _CreatePageState();
|
||||
@@ -111,36 +119,13 @@ class _CreatePageState extends State<CreatePage> {
|
||||
setState(() => tags = [...tags, value]);
|
||||
}
|
||||
|
||||
/// AI 作品的社区发布留待 M4:AI 结果是生成图(无本地文件、无 media
|
||||
/// asset),走不了两步上传,故不接真实发布链路;demo 发布流(写
|
||||
/// `AppState.posts`)随 T3-17 退役,此处只余占位提示。
|
||||
void publish() {
|
||||
if (resultUrl == null || titleController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
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,
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('AI 作品发布随 AI 创作能力上线(M4);发布普通动态请用上方「发布动态」')),
|
||||
);
|
||||
widget.appState.publishPost(post);
|
||||
setState(() {
|
||||
uploaded = false;
|
||||
resultUrl = null;
|
||||
generationStep = 0;
|
||||
titleController.clear();
|
||||
contentController.clear();
|
||||
});
|
||||
widget.onPublished(post);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -148,6 +133,8 @@ class _CreatePageState extends State<CreatePage> {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
children: [
|
||||
_ComposeEntryCard(onTap: widget.onOpenCompose),
|
||||
const SizedBox(height: 18),
|
||||
SegmentedButton<CreationMode>(
|
||||
segments: const [
|
||||
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 {
|
||||
const _UploadCard({
|
||||
required this.uploaded,
|
||||
|
||||
+384
-134
@@ -1,42 +1,252 @@
|
||||
import 'package:flutter/material.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/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/state/app_state.dart';
|
||||
import 'package:patbond_flutter/widgets/common.dart';
|
||||
|
||||
enum HomeSegment { feed, services }
|
||||
|
||||
/// 首页 Tab:Feed 段自 T3-14 起消费 [CommunityController] 真实数据
|
||||
/// (四态 + 游标翻页 + 聚合曝光埋点);天气条/问候卡/搜索/服务段与
|
||||
/// `_StoryRow` 家具保留 demo 形态(03 号评估 §1.1 判定)。
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({
|
||||
required this.appState,
|
||||
required this.onOpenPost,
|
||||
required this.communityController,
|
||||
required this.onOpenServices,
|
||||
required this.onOpenCreate,
|
||||
required this.onOpenCompose,
|
||||
super.key,
|
||||
this.onOpenPost,
|
||||
this.feedAnalytics,
|
||||
this.isActive = true,
|
||||
});
|
||||
|
||||
final AppState appState;
|
||||
final ValueChanged<PostModel> onOpenPost;
|
||||
|
||||
/// Feed 数据源(Tab 级单例,app.dart 装配注入)。
|
||||
final CommunityController communityController;
|
||||
|
||||
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
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
HomeSegment segment = HomeSegment.feed;
|
||||
String query = '';
|
||||
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();
|
||||
if (keyword.isEmpty) return widget.appState.posts;
|
||||
return widget.appState.posts.where((post) {
|
||||
return post.content.toLowerCase().contains(keyword) ||
|
||||
post.authorName.toLowerCase().contains(keyword) ||
|
||||
post.tags.any((tag) => tag.toLowerCase().contains(keyword));
|
||||
if (keyword.isEmpty) return _feed.feed;
|
||||
return _feed.feed.where((card) {
|
||||
return card.contentPreview.toLowerCase().contains(keyword) ||
|
||||
(card.title?.toLowerCase().contains(keyword) ?? false) ||
|
||||
authorDisplayName(card.author).toLowerCase().contains(keyword);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -95,10 +305,32 @@ class _HomePageState extends State<HomePage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
Future<void>.delayed(const Duration(milliseconds: 500)),
|
||||
return ListenableBuilder(
|
||||
listenable: _feed,
|
||||
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(
|
||||
key: _listKey,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
children: [
|
||||
_WeatherStatusBar(
|
||||
@@ -137,40 +369,24 @@ class _HomePageState extends State<HomePage> {
|
||||
],
|
||||
selected: {segment},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (value) {
|
||||
setState(() => segment = value.first);
|
||||
},
|
||||
onSelectionChanged: (value) => _onSegmentChanged(value.first),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (segment == HomeSegment.feed) ...[
|
||||
_StoryRow(onCreate: widget.onOpenCreate),
|
||||
_StoryRow(onCreate: widget.onOpenCompose),
|
||||
const SizedBox(height: 18),
|
||||
_PromoCard(onTap: () => widget.onOpenServices(true)),
|
||||
const SizedBox(height: 18),
|
||||
if (filteredPosts.isEmpty)
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
..._feedSection(),
|
||||
] else ...[
|
||||
_CategoryGrid(onTap: (_) => widget.onOpenServices(false)),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
Text('附近推荐', style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
'附近推荐',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
for (final item in ['综合', '距离', '评分'])
|
||||
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 {
|
||||
const _CategoryGrid({required this.onTap});
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
|
||||
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||
import 'package:patbond_flutter/features/community/post_analytics.dart';
|
||||
import 'package:patbond_flutter/features/community/post_compose_page.dart';
|
||||
import 'package:patbond_flutter/features/create/create_page.dart';
|
||||
import 'package:patbond_flutter/features/home/home_page.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||
@@ -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/profile/profile_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/widgets/common.dart';
|
||||
|
||||
@@ -19,9 +26,15 @@ class MainShellPage extends StatefulWidget {
|
||||
const MainShellPage({
|
||||
required this.appState,
|
||||
required this.petsController,
|
||||
required this.communityController,
|
||||
super.key,
|
||||
this.currentUserId,
|
||||
this.petAnalytics,
|
||||
this.healthRecordAnalytics,
|
||||
this.feedAnalytics,
|
||||
this.interactionAnalytics,
|
||||
this.postAnalytics,
|
||||
this.mediaUploaderFactory,
|
||||
this.pageViewTracker,
|
||||
this.onLogout,
|
||||
});
|
||||
@@ -31,12 +44,30 @@ class MainShellPage extends StatefulWidget {
|
||||
/// 宠物档案状态(T2-11 拆出的独立 pets feature;档案 Tab 数据源)。
|
||||
final PetsController petsController;
|
||||
|
||||
/// 社区状态(T3-12 数据层;首页 Feed segment 数据源,T3-14 接线)。
|
||||
final CommunityController communityController;
|
||||
|
||||
/// 当前登录用户 id(详情页评论删除入口 / 关注钮自见性的 UI 判定)。
|
||||
final String? currentUserId;
|
||||
|
||||
/// pet 域埋点强类型封装(建宠漏斗三事件)。
|
||||
final PetAnalytics? petAnalytics;
|
||||
|
||||
/// health_record 域埋点强类型封装(T2-13 创建漏斗三事件 + viewed)。
|
||||
final HealthRecordAnalytics? healthRecordAnalytics;
|
||||
|
||||
/// feed 域埋点(T3-14 聚合曝光 + 加载失败)。
|
||||
final FeedAnalytics? feedAnalytics;
|
||||
|
||||
/// 互动域埋点(T3-16 评论成败对 + 关注对;详情页消费)。
|
||||
final CommunityInteractionAnalytics? interactionAnalytics;
|
||||
|
||||
/// post 域埋点(T3-17 发布漏斗五事件 + 媒体三段;发布页消费)。
|
||||
final PostAnalytics? postAnalytics;
|
||||
|
||||
/// 发布页 [MediaUploader] 构造口(桌面实测 / 测试替换选图与压缩层)。
|
||||
final MediaUploaderFactory? mediaUploaderFactory;
|
||||
|
||||
/// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。
|
||||
final PageViewTracker? pageViewTracker;
|
||||
|
||||
@@ -83,16 +114,44 @@ class _MainShellPageState extends State<MainShellPage> {
|
||||
widget.pageViewTracker?.reportTab(_tabPages[3]);
|
||||
}
|
||||
|
||||
void openPost(PostModel post) {
|
||||
/// 帖子详情(T3-15:真实数据整页;Feed 卡片与详情共享
|
||||
/// communityController,互动状态跨页一致)。
|
||||
void openPost(String postId) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName),
|
||||
builder: (context) =>
|
||||
PostDetailPage(appState: widget.appState, postId: post.id),
|
||||
builder: (context) => PostDetailPage(
|
||||
controller: widget.communityController,
|
||||
postId: postId,
|
||||
currentUserId: widget.currentUserId,
|
||||
analytics: widget.interactionAnalytics,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 发布页(T3-17:真实发布链路)。发布成功后回首页 Feed 并整体刷新,
|
||||
/// 新帖按 `(published_at DESC, id DESC)` 落在首位(跨客户端同一序)。
|
||||
Future<void> openCompose(PostEntryPoint entryPoint) async {
|
||||
final published = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute<bool>(
|
||||
settings: RouteSettings(name: AnalyticsPageName.postForm.pageName),
|
||||
builder: (context) => PostComposePage(
|
||||
controller: widget.communityController,
|
||||
entryPoint: entryPoint,
|
||||
analytics: widget.postAnalytics,
|
||||
uploaderFactory: widget.mediaUploaderFactory,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!mounted || published != true) return;
|
||||
selectTab(0);
|
||||
unawaited(widget.communityController.refresh());
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('已发布,去首页看看吧 🐾')));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
@@ -107,18 +166,17 @@ class _MainShellPageState extends State<MainShellPage> {
|
||||
final pages = [
|
||||
HomePage(
|
||||
appState: widget.appState,
|
||||
onOpenPost: openPost,
|
||||
communityController: widget.communityController,
|
||||
feedAnalytics: widget.feedAnalytics,
|
||||
isActive: currentIndex == 0,
|
||||
onOpenServices: openServices,
|
||||
onOpenCreate: () => selectTab(1),
|
||||
onOpenCompose: () => openCompose(PostEntryPoint.feed),
|
||||
onOpenPost: openPost,
|
||||
),
|
||||
CreatePage(
|
||||
appState: widget.appState,
|
||||
onPublished: (post) {
|
||||
selectTab(0);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => openPost(post),
|
||||
);
|
||||
},
|
||||
// T3-17:发布半边已真实化(发布页 push),AI 生成模拟原样留 M4。
|
||||
onOpenCompose: () => openCompose(PostEntryPoint.createTab),
|
||||
),
|
||||
PetsPage(
|
||||
controller: widget.petsController,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
||||
library;
|
||||
|
||||
export 'package:patbond_flutter/core/models/cursor_page.dart';
|
||||
|
||||
/// 物种(创建即定,不可修改)。
|
||||
enum PetSpecies {
|
||||
dog,
|
||||
@@ -124,33 +126,6 @@ String dateToJson(DateTime date) {
|
||||
DateTime? _dateOrNull(Object? value) =>
|
||||
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 存在。
|
||||
class Pet {
|
||||
|
||||
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: '1.8k', label: '获赞'),
|
||||
_ProfileStat(
|
||||
value: '${appState.posts.length}',
|
||||
// 「我的资料」头部整体仍是 demo 家具(M5 范围):三项
|
||||
// 统计同源 demo 常量;AppState.posts 随 T3-17 退役后
|
||||
// 本项直读 demo 列表长度,不伪装真实数据。
|
||||
value: '${initialPosts.length}',
|
||||
label: '我的作品',
|
||||
),
|
||||
],
|
||||
|
||||
@@ -7,13 +7,16 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppState extends ChangeNotifier {
|
||||
static const _petKey = 'patbond_pet';
|
||||
static const _postsKey = 'patbond_posts';
|
||||
static const _locationWeatherKey = 'patbond_location_weather';
|
||||
|
||||
/// 首页问候卡 / 创作页 / 主壳头像仍消费的 demo 宠物(T2-12 起档案
|
||||
/// 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;
|
||||
List<PostModel> posts = List<PostModel>.from(initialPosts);
|
||||
LocationWeather locationWeather = initialLocationWeather;
|
||||
bool isReady = false;
|
||||
|
||||
@@ -21,17 +24,11 @@ class AppState extends ChangeNotifier {
|
||||
try {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final savedPet = preferences.getString(_petKey);
|
||||
final savedPosts = preferences.getString(_postsKey);
|
||||
final savedLocationWeather = preferences.getString(_locationWeatherKey);
|
||||
|
||||
if (savedPet != null) {
|
||||
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) {
|
||||
locationWeather = LocationWeather.fromJson(
|
||||
jsonDecode(savedLocationWeather) as Map<String, dynamic>,
|
||||
@@ -40,7 +37,6 @@ class AppState extends ChangeNotifier {
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
|
||||
pet = initialPet;
|
||||
posts = List<PostModel>.from(initialPosts);
|
||||
locationWeather = initialLocationWeather;
|
||||
} finally {
|
||||
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 {
|
||||
locationWeather = value;
|
||||
notifyListeners();
|
||||
@@ -71,21 +52,15 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
Future<void> resetDemoData() async {
|
||||
pet = initialPet;
|
||||
posts = List<PostModel>.from(initialPosts);
|
||||
locationWeather = initialLocationWeather;
|
||||
notifyListeners();
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
await Future.wait([
|
||||
preferences.remove(_petKey),
|
||||
preferences.remove(_postsKey),
|
||||
preferences.remove(_locationWeatherKey),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _savePosts() {
|
||||
return _save(_postsKey, posts.map((post) => post.toJson()).toList());
|
||||
}
|
||||
|
||||
Future<void> _save(String key, Object value) async {
|
||||
try {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/network/signed_network_image.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
|
||||
class RemoteImage extends StatelessWidget {
|
||||
@@ -21,8 +22,10 @@ class RemoteImage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: Image.network(
|
||||
url,
|
||||
// 缓存 key 剥离预签名参数(T3-14):媒体 URL 每次响应现签,
|
||||
// 按完整 URL 缓存会同图重复下载。
|
||||
child: Image(
|
||||
image: SignedNetworkImage(url),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
|
||||
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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import file_selector_macos
|
||||
import flutter_image_compress_macos
|
||||
import flutter_secure_storage_darwin
|
||||
import package_info_plus
|
||||
import shared_preferences_foundation
|
||||
|
||||
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"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
+200
-1
@@ -57,6 +57,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -90,7 +98,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
@@ -121,6 +129,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -134,6 +174,59 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -142,6 +235,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -200,6 +301,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -224,6 +330,75 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -424,6 +599,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.6"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -533,6 +716,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -589,6 +780,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -39,10 +39,19 @@ dependencies:
|
||||
flutter_secure_storage: ^11.0.0
|
||||
uuid: ^4.6.0
|
||||
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:
|
||||
flutter_test:
|
||||
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
|
||||
# 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(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 行为是整批清空)。
|
||||
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);
|
||||
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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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 'helpers/auth_test_helpers.dart';
|
||||
import 'helpers/community_test_helpers.dart';
|
||||
import 'helpers/pet_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
@@ -17,6 +18,8 @@ void main() {
|
||||
sessionManager: session,
|
||||
authRepository: FakeAuthRepository(),
|
||||
petsRepository: FakePetsRepository(),
|
||||
communityRepository: FakeCommunityRepository()
|
||||
..onFeed = (_, _) async => feedPage(const []),
|
||||
),
|
||||
);
|
||||
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 <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user