Compare commits
30 Commits
ee77361b92
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e87413360 | |||
| 9892b65a19 | |||
| f873acf9a3 | |||
| 92524da8e2 | |||
| 8aac8c52cc | |||
| 1441f0148f | |||
| 19bd8c1810 | |||
| 66f983d680 | |||
| 4d40c38f06 | |||
| 720865bcb9 | |||
| ba503327f5 | |||
| e186ba3da9 | |||
| c91f18a845 | |||
| 5b34fa336b | |||
| 97a1f46e3f | |||
| c0a8a56e91 | |||
| 3179528c57 | |||
| 7fb9031f8d | |||
| 33b993ca0c | |||
| 1afec6aad1 | |||
| 8ea6265046 | |||
| 6fef0db009 | |||
| 4c2f839a01 | |||
| 3f8388e5d4 | |||
| 45f94d2558 | |||
| b0207c9972 | |||
| 60d67a355b | |||
| 845e92fa7d | |||
| da25804a06 | |||
| 8d890c0424 |
@@ -0,0 +1,58 @@
|
|||||||
|
# Gitea Actions 门禁:与 docs/development/git-workflow.md 的本地门禁同一套命令。
|
||||||
|
# 零外部 action / 零 GitHub 依赖(原因见 patbond-api 的 ci.yml 头注释)。
|
||||||
|
# Flutter SDK 从中国镜像(flutter-io.cn)下载并缓存在 runner 的 toolcache 卷中,
|
||||||
|
# 首跑约需下载 ~900MB,之后复用缓存秒级就绪。
|
||||||
|
# 前提:runner config.yaml 的 container.options 挂载了 toolcache 卷:
|
||||||
|
# options: "-v /var/run/docker.sock:/var/run/docker.sock -v gitea_toolcache:/opt/hostedtoolcache"
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
flutter-gates:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout (manual)
|
||||||
|
run: |
|
||||||
|
git init -q .
|
||||||
|
AUTH_URL=$(echo "${{ github.server_url }}" | sed "s#https://#https://oauth2:${{ github.token }}@#")
|
||||||
|
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
|
||||||
|
FLUTTER_HOME=/opt/hostedtoolcache/flutter-$FLUTTER_VERSION
|
||||||
|
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
|
||||||
|
echo "Downloading Flutter $FLUTTER_VERSION from flutter-io.cn ..."
|
||||||
|
curl -fsSL -o /tmp/flutter.tar.xz \
|
||||||
|
"https://storage.flutter-io.cn/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
|
||||||
|
mkdir -p /opt/hostedtoolcache
|
||||||
|
tar -xf /tmp/flutter.tar.xz -C /tmp
|
||||||
|
mv /tmp/flutter "$FLUTTER_HOME"
|
||||||
|
else
|
||||||
|
echo "Reusing cached Flutter at $FLUTTER_HOME"
|
||||||
|
fi
|
||||||
|
git config --global --add safe.directory "$FLUTTER_HOME"
|
||||||
|
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
|
||||||
|
echo "PUB_HOSTED_URL=https://pub.flutter-io.cn" >> "$GITHUB_ENV"
|
||||||
|
echo "FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn" >> "$GITHUB_ENV"
|
||||||
|
# 与本地门禁同一套命令(git-workflow.md)
|
||||||
|
- name: Gates
|
||||||
|
run: |
|
||||||
|
flutter --version
|
||||||
|
flutter pub get
|
||||||
|
dart format --output=none --set-exit-if-changed lib test
|
||||||
|
flutter analyze
|
||||||
|
flutter test
|
||||||
@@ -31,7 +31,7 @@ flutter run -d chrome
|
|||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dart format --set-exit-if-changed lib test
|
dart format --output=none --set-exit-if-changed lib test
|
||||||
flutter analyze
|
flutter analyze
|
||||||
flutter test
|
flutter test
|
||||||
flutter build apk --debug
|
flutter build apk --debug
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// 13 号规范 §3.3 分段持久化事件队列(shared_preferences 版)。
|
||||||
|
///
|
||||||
|
/// 事件按段(每段最多 [segmentCapacity] 条)存储,避免单 key 整队列
|
||||||
|
/// 重写的 O(n) 放大;总量超过 [maxEvents] 时丢最旧的整段。内存中的
|
||||||
|
/// 段列表是唯一事实来源,shared_preferences 是尽力而为的镜像——
|
||||||
|
/// 持久化不可用(如未初始化插件的测试环境)时降级为纯内存队列,
|
||||||
|
/// 任何存取失败都不向调用方抛出。
|
||||||
|
///
|
||||||
|
/// at-least-once 语义:段只在上传拿到终态(202 受理 / 4xx 永久拒绝)
|
||||||
|
/// 后由调用方删除;应用在响应前被杀,事件仍在本地,冷启动 [restore]
|
||||||
|
/// 恢复后重发,服务端靠 eventId 幂等去重。
|
||||||
|
class AnalyticsEventStore {
|
||||||
|
AnalyticsEventStore({this.maxEvents = 500, this.segmentCapacity = 20});
|
||||||
|
|
||||||
|
/// 段 ID 有序列表(旧 → 新),JSON 数组。
|
||||||
|
static const segIndexKey = 'pb.analytics.segIndex';
|
||||||
|
|
||||||
|
/// 单段序列化事件 JSON 数组,key 为前缀 + 段 ID。
|
||||||
|
static const segKeyPrefix = 'pb.analytics.seg.';
|
||||||
|
|
||||||
|
/// 本地累计丢弃计数(溢出淘汰 + 4xx 丢批 + 损坏段),诊断用。
|
||||||
|
static const droppedCountKey = 'pb.analytics.droppedCount';
|
||||||
|
|
||||||
|
final int maxEvents;
|
||||||
|
final int segmentCapacity;
|
||||||
|
|
||||||
|
SharedPreferences? _prefs;
|
||||||
|
final List<_Segment> _segments = [];
|
||||||
|
int _seq = 0;
|
||||||
|
int _droppedCount = 0;
|
||||||
|
|
||||||
|
int get length => _segments.fold(0, (sum, seg) => sum + seg.events.length);
|
||||||
|
|
||||||
|
int get droppedCount => _droppedCount;
|
||||||
|
|
||||||
|
/// 全部待上传事件快照(旧 → 新)。
|
||||||
|
List<Map<String, dynamic>> get events =>
|
||||||
|
List.unmodifiable([for (final seg in _segments) ...seg.events]);
|
||||||
|
|
||||||
|
/// 冷启动恢复:读回未上传段。损坏段(JSON 解析失败)删 key 丢弃、
|
||||||
|
/// 计入丢弃数,不崩溃;索引本身损坏则按 key 前缀清扫孤儿段后重建。
|
||||||
|
/// 恢复的事件排在本实例已入队事件之前(更旧优先上传/淘汰)。
|
||||||
|
Future<void> restore() async {
|
||||||
|
try {
|
||||||
|
_prefs = await SharedPreferences.getInstance();
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Analytics store: prefs unavailable, memory-only: $error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final prefs = _prefs!;
|
||||||
|
_droppedCount = prefs.getInt(droppedCountKey) ?? 0;
|
||||||
|
|
||||||
|
final restored = <_Segment>[];
|
||||||
|
final rawIndex = prefs.getString(segIndexKey);
|
||||||
|
if (rawIndex != null) {
|
||||||
|
var ids = const <String>[];
|
||||||
|
try {
|
||||||
|
ids = [for (final id in jsonDecode(rawIndex) as List) id.toString()];
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Analytics store: corrupted index, sweeping: $error');
|
||||||
|
final orphanKeys = prefs
|
||||||
|
.getKeys()
|
||||||
|
.where((key) => key.startsWith(segKeyPrefix))
|
||||||
|
.toList();
|
||||||
|
for (final key in orphanKeys) {
|
||||||
|
await _guard(() => prefs.remove(key));
|
||||||
|
}
|
||||||
|
_droppedCount += 1;
|
||||||
|
}
|
||||||
|
for (final id in ids) {
|
||||||
|
final raw = prefs.getString('$segKeyPrefix$id');
|
||||||
|
if (raw == null) continue;
|
||||||
|
try {
|
||||||
|
final events = [
|
||||||
|
for (final event in jsonDecode(raw) as List)
|
||||||
|
Map<String, dynamic>.from(event as Map),
|
||||||
|
];
|
||||||
|
if (events.isNotEmpty) {
|
||||||
|
restored.add(_Segment(id, events, sealed: true));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Analytics store: dropping corrupted seg $id: $error');
|
||||||
|
await _guard(() => prefs.remove('$segKeyPrefix$id'));
|
||||||
|
_droppedCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restore 前入队的事件此前无处可写,恢复后连同清理过的索引一并落盘。
|
||||||
|
final preExisting = List<_Segment>.of(_segments);
|
||||||
|
_segments
|
||||||
|
..clear()
|
||||||
|
..addAll(restored)
|
||||||
|
..addAll(preExisting);
|
||||||
|
await _evictOverflow();
|
||||||
|
for (final seg in preExisting.where(_segments.contains)) {
|
||||||
|
await _persistSegment(seg);
|
||||||
|
}
|
||||||
|
await _persistIndex();
|
||||||
|
await _persistDroppedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 追加事件到当前开放段并持久化该段(重写 ≤ [segmentCapacity] 条);
|
||||||
|
/// 段满即封段,下次写入开新段;超总量上限丢最旧整段。
|
||||||
|
Future<void> add(Map<String, dynamic> event) async {
|
||||||
|
var open = _segments.isEmpty ? null : _segments.last;
|
||||||
|
if (open == null || open.sealed || open.events.length >= segmentCapacity) {
|
||||||
|
open = _Segment(_newSegId(), []);
|
||||||
|
_segments.add(open);
|
||||||
|
await _persistIndex();
|
||||||
|
}
|
||||||
|
open.events.add(event);
|
||||||
|
if (open.events.length >= segmentCapacity) open.sealed = true;
|
||||||
|
await _persistSegment(open);
|
||||||
|
await _evictOverflow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从最旧段起取整段组一批(≤ [maxBatchEvents] 条)。入选段即封段,
|
||||||
|
/// 上传在途期间新事件只会写入新的开放段,批内容与对应段不再变化,
|
||||||
|
/// 上传成功后可安全整段删除(冲刷中新事件不丢)。
|
||||||
|
UploadBatch takeBatch(int maxBatchEvents) {
|
||||||
|
final selected = <_Segment>[];
|
||||||
|
var count = 0;
|
||||||
|
for (final seg in _segments) {
|
||||||
|
if (count + seg.events.length > maxBatchEvents) break;
|
||||||
|
selected.add(seg);
|
||||||
|
count += seg.events.length;
|
||||||
|
}
|
||||||
|
for (final seg in selected) {
|
||||||
|
seg.sealed = true;
|
||||||
|
}
|
||||||
|
return UploadBatch(
|
||||||
|
segmentIds: [for (final seg in selected) seg.id],
|
||||||
|
events: [
|
||||||
|
for (final seg in selected)
|
||||||
|
...seg.events.map(Map<String, dynamic>.from),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传终态后删段:202 受理或 4xx 永久拒绝(后者 [countAsDropped]
|
||||||
|
/// 计入丢弃诊断数)。段可能已被溢出淘汰,缺失即忽略。
|
||||||
|
Future<void> removeSegments(
|
||||||
|
List<String> ids, {
|
||||||
|
bool countAsDropped = false,
|
||||||
|
}) async {
|
||||||
|
final idSet = ids.toSet();
|
||||||
|
var removed = 0;
|
||||||
|
_segments.removeWhere((seg) {
|
||||||
|
if (!idSet.contains(seg.id)) return false;
|
||||||
|
removed += seg.events.length;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (countAsDropped) _droppedCount += removed;
|
||||||
|
for (final id in ids) {
|
||||||
|
await _removeSegmentKey(id);
|
||||||
|
}
|
||||||
|
await _persistIndex();
|
||||||
|
if (countAsDropped) await _persistDroppedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _evictOverflow() async {
|
||||||
|
var indexDirty = false;
|
||||||
|
while (length > maxEvents && _segments.length > 1) {
|
||||||
|
final victim = _segments.removeAt(0);
|
||||||
|
_droppedCount += victim.events.length;
|
||||||
|
await _removeSegmentKey(victim.id);
|
||||||
|
indexDirty = true;
|
||||||
|
}
|
||||||
|
if (indexDirty) {
|
||||||
|
await _persistIndex();
|
||||||
|
await _persistDroppedCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistIndex() => _guard(
|
||||||
|
() => _prefs?.setString(
|
||||||
|
segIndexKey,
|
||||||
|
jsonEncode([for (final seg in _segments) seg.id]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _persistSegment(_Segment seg) => _guard(
|
||||||
|
() => _prefs?.setString('$segKeyPrefix${seg.id}', jsonEncode(seg.events)),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _removeSegmentKey(String id) =>
|
||||||
|
_guard(() => _prefs?.remove('$segKeyPrefix$id'));
|
||||||
|
|
||||||
|
Future<void> _persistDroppedCount() =>
|
||||||
|
_guard(() => _prefs?.setInt(droppedCountKey, _droppedCount));
|
||||||
|
|
||||||
|
/// 持久化是尽力而为的镜像,失败只打日志,绝不影响内存队列。
|
||||||
|
Future<void> _guard(Future<void>? Function() write) async {
|
||||||
|
try {
|
||||||
|
await write();
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Analytics store: persistence write failed: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _newSegId() =>
|
||||||
|
'${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}-${_seq++}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次上传的段快照:事件按旧 → 新排列,段 ID 用于成功后删段。
|
||||||
|
class UploadBatch {
|
||||||
|
const UploadBatch({required this.segmentIds, required this.events});
|
||||||
|
|
||||||
|
final List<String> segmentIds;
|
||||||
|
final List<Map<String, dynamic>> events;
|
||||||
|
|
||||||
|
bool get isEmpty => events.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Segment {
|
||||||
|
_Segment(this.id, this.events, {this.sealed = false});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final List<Map<String, dynamic>> events;
|
||||||
|
|
||||||
|
/// 封段后不再接受追加(满 20 条或已被取入上传批次)。
|
||||||
|
bool sealed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/// page_viewed 的 pageName 编译期枚举(06 号报告 §5.2 字典 v2 + 03 号评估
|
||||||
|
/// §3.2 Tab 映射)。禁止自由路由字符串;带参数路由必须归一化,任何
|
||||||
|
/// UUID/ID 不得出现在 pageName 或 referrer 中(隐私红线第 5 条)。
|
||||||
|
///
|
||||||
|
/// 尚不存在的 M2 页面(petList/petDetail/petForm/recordForm/recordDetail)
|
||||||
|
/// 先留枚举定义、不接线,待健康档案页面族落地时启用。
|
||||||
|
enum AnalyticsPageName {
|
||||||
|
// —— 字典 v2 初始集合(06 §5.2 验收 2)——
|
||||||
|
login('login'),
|
||||||
|
register('register'),
|
||||||
|
home('home'),
|
||||||
|
profile('profile'),
|
||||||
|
petList('pet_list'),
|
||||||
|
petDetail('pet_detail'),
|
||||||
|
petForm('pet_form'),
|
||||||
|
recordForm('record_form'),
|
||||||
|
recordDetail('record_detail'),
|
||||||
|
// —— 客户端现存页面/Tab 补充(03 §3.2 Tab 映射,需数据侧同步进字典)——
|
||||||
|
create('create'),
|
||||||
|
petArchive('pet_archive'),
|
||||||
|
services('services'),
|
||||||
|
postDetail('post_detail'),
|
||||||
|
// —— 字典 v3 页面族(22 号报告 §2;本枚举登记 M3 已落地页)——
|
||||||
|
/// 发布页(P3,T3-17 push 全屏页)。
|
||||||
|
postForm('post_form');
|
||||||
|
|
||||||
|
const AnalyticsPageName(this.pageName);
|
||||||
|
|
||||||
|
/// 上报值,snake_case,与 RouteSettings.name 一致。
|
||||||
|
final String pageName;
|
||||||
|
|
||||||
|
static final Map<String, AnalyticsPageName> _byName = {
|
||||||
|
for (final page in values) page.pageName: page,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 路由名 → 枚举;不在枚举内返回 null(调用方不上报,06 §5.2 验收 4)。
|
||||||
|
static AnalyticsPageName? fromRouteName(String? name) =>
|
||||||
|
name == null ? null : _byName[name];
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
|
||||||
|
/// 集中式 page_viewed 路由埋点(03 号评估 §3.2:NavigatorObserver 派生,
|
||||||
|
/// 页面零侵入;06 号报告 §5.2 验收 1/4)。
|
||||||
|
///
|
||||||
|
/// 只统计 [PageRoute](bottom sheet / dialog 是 PopupRoute,不计曝光);
|
||||||
|
/// 路由名不在 [AnalyticsPageName] 枚举内不上报(后端会整条 rejected,
|
||||||
|
/// 不白白消耗队列)。didPop 补报「回退后重新曝光的前一页」;弹回到
|
||||||
|
/// 无名根路由(认证状态机的 AnimatedSwitcher 宿主)时经 [resolveRootPage]
|
||||||
|
/// 解析当前根部页面。
|
||||||
|
class AnalyticsRouteObserver extends NavigatorObserver {
|
||||||
|
AnalyticsRouteObserver({required this.tracker, this.resolveRootPage});
|
||||||
|
|
||||||
|
final PageViewTracker tracker;
|
||||||
|
|
||||||
|
/// 根路由(无 RouteSettings.name)当前展示页的解析器,由 app.dart
|
||||||
|
/// 按认证状态与主壳当前 Tab 提供;返回 null 则不补报。
|
||||||
|
final AnalyticsPageName? Function()? resolveRootPage;
|
||||||
|
|
||||||
|
void _reportRoute(Route<dynamic>? route) {
|
||||||
|
if (route is! PageRoute) return;
|
||||||
|
final page = AnalyticsPageName.fromRouteName(route.settings.name);
|
||||||
|
if (page != null) tracker.report(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||||
|
_reportRoute(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||||
|
_reportRoute(newRoute);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||||
|
if (previousRoute is! PageRoute) return;
|
||||||
|
final page = AnalyticsPageName.fromRouteName(previousRoute.settings.name);
|
||||||
|
if (page != null) {
|
||||||
|
tracker.report(page);
|
||||||
|
} else if (previousRoute.isFirst) {
|
||||||
|
final rootPage = resolveRootPage?.call();
|
||||||
|
if (rootPage != null) tracker.report(rootPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
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, 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,
|
||||||
|
required this.getAccessToken,
|
||||||
|
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(),
|
||||||
|
_now = now ?? DateTime.now;
|
||||||
|
|
||||||
|
/// 异步设置 appVersion(app.dart 启动时从 package_info_plus 读取后注入)。
|
||||||
|
void setAppVersion(String version) {
|
||||||
|
_appVersion = version;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 满 _flushThreshold 条触发一次冲刷(13 号规范 §3.4)。
|
||||||
|
static const _flushThreshold = 20;
|
||||||
|
|
||||||
|
// 契约单批上限(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;
|
||||||
|
|
||||||
|
/// 前台定时冲刷周期(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() {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return 'web-unknown';
|
||||||
|
}
|
||||||
|
final major = RegExp(
|
||||||
|
r'\d+',
|
||||||
|
).firstMatch(Platform.operatingSystemVersion)?.group(0);
|
||||||
|
return '${Platform.operatingSystem}-${major ?? 'unknown'}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 平台标识。契约枚举为 android/ios;Web/桌面为开发调试形态,
|
||||||
|
/// 上报值不在枚举内会被服务端逐条 rejected(不影响客户端),属预期。
|
||||||
|
static String _platformName() {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return 'android';
|
||||||
|
}
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return 'ios';
|
||||||
|
}
|
||||||
|
return Platform.operatingSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets userId after login/restore.
|
||||||
|
void identify(String userId) {
|
||||||
|
_userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears userId on logout (anonymousId remains).
|
||||||
|
void reset() {
|
||||||
|
_userId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks event (never throws, never awaits network). Props are validated
|
||||||
|
/// for privacy red-line patterns locally before queuing.
|
||||||
|
Future<void> trackEvent(
|
||||||
|
String eventName, [
|
||||||
|
Map<String, dynamic>? props,
|
||||||
|
]) async {
|
||||||
|
try {
|
||||||
|
if (props != null && _containsForbiddenField(props)) {
|
||||||
|
debugPrint('Analytics: event $eventName rejected (forbidden field)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final event = {
|
||||||
|
'eventId': const Uuid().v7(),
|
||||||
|
'eventName': eventName,
|
||||||
|
'eventVersion': 1,
|
||||||
|
'anonymousId': _anonymousId,
|
||||||
|
if (_userId != null) 'userId': _userId,
|
||||||
|
'sessionId': getSessionId(),
|
||||||
|
'clientTs': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
'appVersion': _appVersion,
|
||||||
|
'platform': _platformName(),
|
||||||
|
'osVersion': _osVersion,
|
||||||
|
if (props != null && props.isNotEmpty) 'props': props,
|
||||||
|
};
|
||||||
|
|
||||||
|
await _store.add(event);
|
||||||
|
if (_store.length >= _flushThreshold) {
|
||||||
|
await _flush();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Analytics track failed: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 冷启动恢复:先采用持久化 anonymousId,再恢复持久化队列(离线积压
|
||||||
|
/// 约两周容量),有积压即冲刷一次(13 号规范 §3.4 冷启动触发)。
|
||||||
|
/// app 启动时调用,不阻塞渲染。
|
||||||
|
Future<void> restore() async {
|
||||||
|
try {
|
||||||
|
await _restoreAnonymousId();
|
||||||
|
await _store.restore();
|
||||||
|
if (_store.length > 0) {
|
||||||
|
await _flush();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Analytics restore failed: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 采用/落盘持久化 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();
|
||||||
|
|
||||||
|
Future<void> _flush() async {
|
||||||
|
if (_flushing) return;
|
||||||
|
_flushing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
// 取段拼批(入选段即封段,冲刷中的新事件写入新开放段不会丢)。
|
||||||
|
final batch = _store.takeBatch(_maxBatchEvents);
|
||||||
|
if (batch.isEmpty) break;
|
||||||
|
final rejected = await uploadBatch(batch.events);
|
||||||
|
// 拿到服务端应答即连通性恢复,重置退避。
|
||||||
|
_resetBackoff();
|
||||||
|
// at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段;
|
||||||
|
// 4xx 批次计入本地丢弃诊断数。
|
||||||
|
await _store.removeSegments(batch.segmentIds, countAsDropped: rejected);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 网络错误 / 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 / 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'))
|
||||||
|
..headers.contentType = ContentType.json;
|
||||||
|
if (token != null) {
|
||||||
|
request.headers.add('Authorization', 'Bearer $token');
|
||||||
|
}
|
||||||
|
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 为永久性拒绝(校验失败/批量超限等),重试不可能成功;
|
||||||
|
// 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。
|
||||||
|
debugPrint(
|
||||||
|
'Analytics batch permanently rejected '
|
||||||
|
'(${response.statusCode}), dropping ${events.length} events',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (response.statusCode != 202) {
|
||||||
|
throw Exception('Upload failed with ${response.statusCode}');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _containsForbiddenField(Map<String, dynamic> props) {
|
||||||
|
// Dart RegExp 不支持 (?i) 内联标志(原写法构造即抛异常,事件被静默丢弃)。
|
||||||
|
final pattern = RegExp(
|
||||||
|
r'password|token|secret|phone|mobile|email|credential|idfa|gaid',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
return props.keys.any((key) => pattern.hasMatch(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
|
||||||
|
/// trackEvent 形状的回调(生产传 `AnalyticsService.trackEvent`,测试传录制桩)。
|
||||||
|
typedef TrackEventFn =
|
||||||
|
Future<void> Function(String eventName, [Map<String, dynamic>? props]);
|
||||||
|
|
||||||
|
/// page_viewed 上报的单一出口:维护 referrer 链与去重。
|
||||||
|
///
|
||||||
|
/// 路由事件(AnalyticsRouteObserver)、主壳 Tab 切换、认证状态机切页
|
||||||
|
/// 三类曝光都经此上报,referrer(前一页 pageName)才能跨机制连贯;
|
||||||
|
/// 栈底/冷启动首页 referrer 为 null(06 §5.2 验收 3)。
|
||||||
|
class PageViewTracker {
|
||||||
|
PageViewTracker(this._track);
|
||||||
|
|
||||||
|
final TrackEventFn _track;
|
||||||
|
|
||||||
|
String? _lastPageName;
|
||||||
|
AnalyticsPageName? _currentTab;
|
||||||
|
|
||||||
|
/// 主壳当前 Tab(供回栈补报解析根路由曝光页)。
|
||||||
|
AnalyticsPageName? get currentTab => _currentTab;
|
||||||
|
|
||||||
|
/// 上报一次页面曝光;与上一条相同的页面去重(Tab 重复点选等)。
|
||||||
|
void report(AnalyticsPageName page) {
|
||||||
|
if (page.pageName == _lastPageName) return;
|
||||||
|
final referrer = _lastPageName;
|
||||||
|
_lastPageName = page.pageName;
|
||||||
|
final props = <String, dynamic>{'pageName': page.pageName};
|
||||||
|
if (referrer != null) props['referrer'] = referrer;
|
||||||
|
_track('page_viewed', props);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 主壳 Tab 曝光:记录当前 Tab 后上报(IndexedStack 无路由事件,手动补点)。
|
||||||
|
void reportTab(AnalyticsPageName page) {
|
||||||
|
_currentTab = page;
|
||||||
|
report(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// sessionId 生命周期管理(13 号规范 §3.1、06 号报告 §5.1)。
|
||||||
|
///
|
||||||
|
/// 语义三条:冷启动生成新 sessionId;`paused → resumed` 间隔超过
|
||||||
|
/// [timeout](默认 30 分钟)生成新 sessionId;未超过则沿用原值。
|
||||||
|
/// sessionId 不落任何持久化存储——会话本该跨冷启动失效(03 号评估 §3.1,
|
||||||
|
/// 纯内存方案,lastActiveAt 不持久化)。
|
||||||
|
///
|
||||||
|
/// 注意生命周期级联:前台恢复时状态机会依次经过
|
||||||
|
/// `paused → hidden → inactive → resumed`,因此只在**离开 resumed 的第一次
|
||||||
|
/// 变更**记录退后台时刻,后续级联状态不得覆盖,否则间隔永远趋近于零。
|
||||||
|
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();
|
||||||
|
|
||||||
|
/// 后台超时阈值;构造参数化便于测试注入。
|
||||||
|
final Duration timeout;
|
||||||
|
|
||||||
|
/// 首次离开前台时回调(app 装配层用于触发埋点队列冲刷)。
|
||||||
|
final VoidCallback? onLeaveForeground;
|
||||||
|
|
||||||
|
/// 回到前台时回调(app 装配层用于恢复 30 秒定时冲刷);
|
||||||
|
/// 冷启动首个 resumed 不触发(此前未离开过前台)。
|
||||||
|
final VoidCallback? onEnterForeground;
|
||||||
|
|
||||||
|
/// 时钟注入口,测试免真实等待。
|
||||||
|
final DateTime Function() _now;
|
||||||
|
|
||||||
|
String _sessionId;
|
||||||
|
DateTime? _leftForegroundAt;
|
||||||
|
AppLifecycleState _lastState = AppLifecycleState.resumed;
|
||||||
|
|
||||||
|
/// 当前会话标识(UUIDv7)。
|
||||||
|
String get sessionId => _sessionId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
final leftAt = _leftForegroundAt;
|
||||||
|
if (leftAt != null) {
|
||||||
|
if (_now().difference(leftAt) > timeout) {
|
||||||
|
_sessionId = const Uuid().v7();
|
||||||
|
}
|
||||||
|
onEnterForeground?.call();
|
||||||
|
}
|
||||||
|
_leftForegroundAt = null;
|
||||||
|
} else if (_lastState == AppLifecycleState.resumed) {
|
||||||
|
// 首次离开前台才记时;inactive/hidden/paused 级联不覆盖。
|
||||||
|
_leftForegroundAt = _now();
|
||||||
|
onLeaveForeground?.call();
|
||||||
|
}
|
||||||
|
_lastState = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
+250
-2
@@ -1,10 +1,48 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_route_observer.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_service.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/session_tracker.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/token_refresher.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/auth_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/login_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/splash_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/post_analytics.dart';
|
||||||
import 'package:patbond_flutter/features/main/main_shell_page.dart';
|
import 'package:patbond_flutter/features/main/main_shell_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
import 'package:patbond_flutter/state/app_state.dart';
|
import 'package:patbond_flutter/state/app_state.dart';
|
||||||
|
|
||||||
class App extends StatefulWidget {
|
class App extends StatefulWidget {
|
||||||
const App({super.key});
|
const App({
|
||||||
|
super.key,
|
||||||
|
this.sessionManager,
|
||||||
|
this.authRepository,
|
||||||
|
this.petsRepository,
|
||||||
|
this.communityRepository,
|
||||||
|
this.mediaUploaderFactory,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 测试注入口;生产默认走安全存储 + 真实 API。
|
||||||
|
final SessionManager? sessionManager;
|
||||||
|
final AuthRepository? authRepository;
|
||||||
|
final PetsRepository? petsRepository;
|
||||||
|
final CommunityRepository? communityRepository;
|
||||||
|
|
||||||
|
/// 发布页媒体上传器构造口(桌面实测替换选图/压缩层;生产为 null)。
|
||||||
|
final MediaUploaderFactory? mediaUploaderFactory;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<App> createState() => _AppState();
|
State<App> createState() => _AppState();
|
||||||
@@ -12,26 +50,236 @@ class App extends StatefulWidget {
|
|||||||
|
|
||||||
class _AppState extends State<App> {
|
class _AppState extends State<App> {
|
||||||
late final AppState appState;
|
late final AppState appState;
|
||||||
|
late final SessionManager sessionManager;
|
||||||
|
late final AuthRepository authRepository;
|
||||||
|
late final PetsController petsController;
|
||||||
|
late final CommunityController communityController;
|
||||||
|
late final PetAnalytics petAnalytics;
|
||||||
|
late final HealthRecordAnalytics healthRecordAnalytics;
|
||||||
|
late final FeedAnalytics feedAnalytics;
|
||||||
|
late final CommunityInteractionAnalytics interactionAnalytics;
|
||||||
|
late final PostAnalytics postAnalytics;
|
||||||
|
late final SessionTracker _sessionTracker;
|
||||||
|
late final AnalyticsService _analytics;
|
||||||
|
late final PageViewTracker _pageViewTracker;
|
||||||
|
late final AnalyticsRouteObserver _routeObserver;
|
||||||
|
TokenRefresher? _sharedRefresher;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
appState = AppState()..load();
|
appState = AppState()..load();
|
||||||
|
sessionManager =
|
||||||
|
widget.sessionManager ??
|
||||||
|
SessionManager(store: const SecureTokenStore());
|
||||||
|
|
||||||
|
_sessionTracker = SessionTracker(
|
||||||
|
onLeaveForeground: () {
|
||||||
|
// 退后台:停定时冲刷并立即冲刷一次(既有触发点保留)。
|
||||||
|
_analytics.stopPeriodicFlush();
|
||||||
|
_analytics.flushNow();
|
||||||
|
},
|
||||||
|
onEnterForeground: () => _analytics.startPeriodicFlush(),
|
||||||
|
);
|
||||||
|
WidgetsBinding.instance.addObserver(_sessionTracker);
|
||||||
|
|
||||||
|
_analytics = AnalyticsService(
|
||||||
|
apiBaseUrl: patbondUserApiBaseUrl,
|
||||||
|
getAccessToken: () => sessionManager.accessToken,
|
||||||
|
getSessionId: () => _sessionTracker.sessionId,
|
||||||
|
);
|
||||||
|
_initAppVersion();
|
||||||
|
// 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。
|
||||||
|
_analytics.restore();
|
||||||
|
// 前台期间 30 秒定时冲刷(13 号 §3.4 第 4 触发点)。
|
||||||
|
_analytics.startPeriodicFlush();
|
||||||
|
|
||||||
|
_pageViewTracker = PageViewTracker(_analytics.trackEvent);
|
||||||
|
_routeObserver = AnalyticsRouteObserver(
|
||||||
|
tracker: _pageViewTracker,
|
||||||
|
resolveRootPage: _resolveRootPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
authRepository = widget.authRepository ?? _buildRepository();
|
||||||
|
petsController = PetsController(
|
||||||
|
repository: widget.petsRepository ?? _buildPetsRepository(),
|
||||||
|
);
|
||||||
|
// T3-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 异步读取 appVersion 并注入 analytics(启动后台任务,不阻塞渲染)。
|
||||||
|
Future<void> _initAppVersion() async {
|
||||||
|
try {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
_analytics.setAppVersion('${info.version}+${info.buildNumber}');
|
||||||
|
} catch (error) {
|
||||||
|
// 读取失败回退 'unknown'(AnalyticsService 构造默认值已兜底)。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 三服务分端口直连(ADR-002 无网关),token 刷新单飞经共享
|
||||||
|
/// [TokenRefresher](22 号报告 §7 交接约定)。
|
||||||
|
TokenRefresher _ensureRefresher() {
|
||||||
|
return _sharedRefresher ??= TokenRefresher(
|
||||||
|
dio: buildPatbondDio(session: sessionManager),
|
||||||
|
session: sessionManager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthRepository _buildRepository() {
|
||||||
|
final dio = buildPatbondDio(session: sessionManager);
|
||||||
|
final refresher = _ensureRefresher();
|
||||||
|
final api = ApiClient(
|
||||||
|
dio: dio,
|
||||||
|
session: sessionManager,
|
||||||
|
refresher: refresher,
|
||||||
|
);
|
||||||
|
return ApiAuthRepository(
|
||||||
|
api: api,
|
||||||
|
session: sessionManager,
|
||||||
|
refresher: refresher,
|
||||||
|
analytics: _analytics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PetsRepository _buildPetsRepository() {
|
||||||
|
final dio = buildPatbondDio(
|
||||||
|
session: sessionManager,
|
||||||
|
baseUrl: patbondPetApiBaseUrl,
|
||||||
|
);
|
||||||
|
final api = ApiClient(
|
||||||
|
dio: dio,
|
||||||
|
session: sessionManager,
|
||||||
|
refresher: _ensureRefresher(),
|
||||||
|
);
|
||||||
|
return ApiPetsRepository(api: api);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// community 服务分端口直连(:8084);media 两步上传端点由 user 服务
|
||||||
|
/// (:8082 MediaController)提供,单独建 mediaApi 直连。token 刷新
|
||||||
|
/// 单飞经共享 [TokenRefresher]。
|
||||||
|
CommunityRepository _buildCommunityRepository() {
|
||||||
|
final dio = buildPatbondDio(
|
||||||
|
session: sessionManager,
|
||||||
|
baseUrl: patbondCommunityApiBaseUrl,
|
||||||
|
);
|
||||||
|
final api = ApiClient(
|
||||||
|
dio: dio,
|
||||||
|
session: sessionManager,
|
||||||
|
refresher: _ensureRefresher(),
|
||||||
|
);
|
||||||
|
final mediaDio = buildPatbondDio(
|
||||||
|
session: sessionManager,
|
||||||
|
baseUrl: patbondUserApiBaseUrl,
|
||||||
|
);
|
||||||
|
final mediaApi = ApiClient(
|
||||||
|
dio: mediaDio,
|
||||||
|
session: sessionManager,
|
||||||
|
refresher: _ensureRefresher(),
|
||||||
|
);
|
||||||
|
return ApiCommunityRepository(api: api, mediaApi: mediaApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnalyticsPageName? _resolveRootPage() {
|
||||||
|
// 回栈到无名根路由时解析当前认证状态页/主壳 Tab
|
||||||
|
return switch (sessionManager.status) {
|
||||||
|
AuthStatus.unknown => null,
|
||||||
|
AuthStatus.unauthenticated => AnalyticsPageName.login,
|
||||||
|
AuthStatus.authenticated => _pageViewTracker.currentTab,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reportAuthStateChange() {
|
||||||
|
// 登出即清宠物档案与社区 Feed 内存副本(跨账号不泄漏;重登后重新拉取)。
|
||||||
|
if (sessionManager.status == AuthStatus.unauthenticated) {
|
||||||
|
petsController.reset();
|
||||||
|
communityController.reset();
|
||||||
|
}
|
||||||
|
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
|
||||||
|
final page = switch (sessionManager.status) {
|
||||||
|
AuthStatus.unauthenticated => AnalyticsPageName.login,
|
||||||
|
AuthStatus.authenticated => _pageViewTracker.currentTab,
|
||||||
|
AuthStatus.unknown => null,
|
||||||
|
};
|
||||||
|
if (page != null) _pageViewTracker.report(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_analytics.stopPeriodicFlush();
|
||||||
|
sessionManager.removeListener(_reportAuthStateChange);
|
||||||
|
WidgetsBinding.instance.removeObserver(_sessionTracker);
|
||||||
appState.dispose();
|
appState.dispose();
|
||||||
|
petsController.dispose();
|
||||||
|
communityController.dispose();
|
||||||
|
if (widget.sessionManager == null) sessionManager.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _homeForStatus() {
|
||||||
|
switch (sessionManager.status) {
|
||||||
|
case AuthStatus.unknown:
|
||||||
|
return SplashPage(
|
||||||
|
key: const ValueKey('splash'),
|
||||||
|
authRepository: authRepository,
|
||||||
|
sessionManager: sessionManager,
|
||||||
|
);
|
||||||
|
case AuthStatus.unauthenticated:
|
||||||
|
return LoginPage(
|
||||||
|
key: const ValueKey('login'),
|
||||||
|
authRepository: authRepository,
|
||||||
|
);
|
||||||
|
case AuthStatus.authenticated:
|
||||||
|
return MainShellPage(
|
||||||
|
key: const ValueKey('shell'),
|
||||||
|
appState: appState,
|
||||||
|
petsController: petsController,
|
||||||
|
communityController: communityController,
|
||||||
|
currentUserId: sessionManager.userId,
|
||||||
|
petAnalytics: petAnalytics,
|
||||||
|
healthRecordAnalytics: healthRecordAnalytics,
|
||||||
|
feedAnalytics: feedAnalytics,
|
||||||
|
interactionAnalytics: interactionAnalytics,
|
||||||
|
postAnalytics: postAnalytics,
|
||||||
|
mediaUploaderFactory: widget.mediaUploaderFactory,
|
||||||
|
pageViewTracker: _pageViewTracker,
|
||||||
|
onLogout: authRepository.logout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Patbond',
|
title: 'Patbond',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: buildAppTheme(),
|
theme: buildAppTheme(),
|
||||||
home: MainShellPage(appState: appState),
|
navigatorObservers: [_routeObserver],
|
||||||
|
home: ListenableBuilder(
|
||||||
|
listenable: sessionManager,
|
||||||
|
builder: (context, _) {
|
||||||
|
// 认证状态切换统一 300ms 淡入淡出(Splash ↔ 登录 ↔ 主壳,
|
||||||
|
// BrandMark 同构保证品牌区过渡对位,组装稿 §7)。
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: _homeForStatus(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 统一页面切换:300ms 淡入淡出(12 号组装稿 §7 流程约束)。
|
||||||
|
///
|
||||||
|
/// [settings] 从 M2 起必传路由名(page_viewed 取数来源,03 号评估 §2.1),
|
||||||
|
/// 名字用 `AnalyticsPageName` 枚举的 pageName 值。
|
||||||
|
Route<T> fadePageRoute<T>(Widget page, {RouteSettings? settings}) {
|
||||||
|
return PageRouteBuilder<T>(
|
||||||
|
settings: settings,
|
||||||
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 300),
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return FadeTransition(opacity: animation, child: child);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_envelope.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';
|
||||||
|
|
||||||
|
/// API 基地址:`--dart-define=PATBOND_API_BASE_URL=...` 注入,默认本机后端。
|
||||||
|
const String patbondApiBaseUrl = String.fromEnvironment(
|
||||||
|
'PATBOND_API_BASE_URL',
|
||||||
|
defaultValue: 'http://127.0.0.1:8081',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// user 服务基地址(/api/v1/me、/api/v1/events):MVP 阶段 auth 与 user
|
||||||
|
/// 分端口直连(ADR-002 无网关),`--dart-define=PATBOND_USER_API_BASE_URL=...` 注入。
|
||||||
|
const String patbondUserApiBaseUrl = String.fromEnvironment(
|
||||||
|
'PATBOND_USER_API_BASE_URL',
|
||||||
|
defaultValue: 'http://127.0.0.1:8082',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// pet 服务基地址(/api/v1/pets、/api/v1/breeds、/api/v1/vaccine-catalog 等
|
||||||
|
/// pets 域 12 路径):沿用 auth :8081 / user :8082 的分端口直连模式,
|
||||||
|
/// `--dart-define=PATBOND_PET_API_BASE_URL=...` 注入。
|
||||||
|
const String patbondPetApiBaseUrl = String.fromEnvironment(
|
||||||
|
'PATBOND_PET_API_BASE_URL',
|
||||||
|
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] 统一解析成
|
||||||
|
/// 类型化异常,不依赖 dio 的 4xx/5xx 抛错路径。
|
||||||
|
Dio buildPatbondDio({required SessionManager session, String? baseUrl}) {
|
||||||
|
final dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
baseUrl: baseUrl ?? patbondApiBaseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 5),
|
||||||
|
sendTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
|
contentType: Headers.jsonContentType,
|
||||||
|
validateStatus: (_) => true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
dio.interceptors.add(AuthInterceptor(session));
|
||||||
|
return dio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 鉴权拦截器:对标记 [ApiClient.requiresAuthExtra] 的请求附加
|
||||||
|
/// `Authorization: Bearer`,并统一携带设备标识。
|
||||||
|
class AuthInterceptor extends Interceptor {
|
||||||
|
AuthInterceptor(this._session);
|
||||||
|
|
||||||
|
final SessionManager _session;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
final deviceId = _session.deviceId;
|
||||||
|
if (deviceId != null) {
|
||||||
|
options.headers['X-Device-Id'] = deviceId;
|
||||||
|
}
|
||||||
|
if (options.extra[ApiClient.requiresAuthExtra] == true) {
|
||||||
|
final token = _session.accessToken;
|
||||||
|
if (token != null) {
|
||||||
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 客户端封装:错误信封 → 类型化异常,401/40101 → 刷新后重放一次。
|
||||||
|
class ApiClient {
|
||||||
|
ApiClient({
|
||||||
|
required this._dio,
|
||||||
|
required this._session,
|
||||||
|
required this._refresher,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// RequestOptions.extra 标记:该请求需要 Bearer 鉴权。
|
||||||
|
static const requiresAuthExtra = 'patbond.requiresAuth';
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
final SessionManager _session;
|
||||||
|
final TokenRefresher _refresher;
|
||||||
|
|
||||||
|
/// 发起请求并返回信封中的 `data`。
|
||||||
|
///
|
||||||
|
/// 鉴权请求遇 HTTP 401 或 code 40101 时:经单飞刷新后原样重放一次;
|
||||||
|
/// 重放仍失败则清会话并抛 [SessionExpiredException]。
|
||||||
|
Future<Object?> request(
|
||||||
|
String path, {
|
||||||
|
String method = 'POST',
|
||||||
|
Object? body,
|
||||||
|
Map<String, Object?>? query,
|
||||||
|
Map<String, Object?>? headers,
|
||||||
|
bool requiresAuth = false,
|
||||||
|
}) async {
|
||||||
|
var response = await _send(
|
||||||
|
path,
|
||||||
|
method: method,
|
||||||
|
body: body,
|
||||||
|
query: query,
|
||||||
|
headers: headers,
|
||||||
|
requiresAuth: requiresAuth,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requiresAuth && _isAuthFailure(response)) {
|
||||||
|
await _refresher.refresh();
|
||||||
|
response = await _send(
|
||||||
|
path,
|
||||||
|
method: method,
|
||||||
|
body: body,
|
||||||
|
query: query,
|
||||||
|
headers: headers,
|
||||||
|
requiresAuth: requiresAuth,
|
||||||
|
);
|
||||||
|
if (_isAuthFailure(response)) {
|
||||||
|
await _session.clearSession();
|
||||||
|
throw const SessionExpiredException('刷新后重放仍未通过鉴权');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _unwrap(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<Object?>> _send(
|
||||||
|
String path, {
|
||||||
|
required String method,
|
||||||
|
required bool requiresAuth,
|
||||||
|
Object? body,
|
||||||
|
Map<String, Object?>? query,
|
||||||
|
Map<String, Object?>? headers,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _dio.request<Object?>(
|
||||||
|
path,
|
||||||
|
data: body,
|
||||||
|
queryParameters: query,
|
||||||
|
options: Options(
|
||||||
|
method: method,
|
||||||
|
headers: headers,
|
||||||
|
extra: {requiresAuthExtra: requiresAuth},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on DioException catch (error) {
|
||||||
|
throw ApiNetworkException('请求失败:${error.type.name}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isAuthFailure(Response<Object?> response) {
|
||||||
|
if (response.statusCode == 401) return true;
|
||||||
|
return ApiEnvelope.tryParse(response.data)?.code ==
|
||||||
|
ApiCodes.accessTokenInvalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object? _unwrap(Response<Object?> response) {
|
||||||
|
final status = response.statusCode ?? 0;
|
||||||
|
if (status == 429) {
|
||||||
|
throw const ApiRateLimitException();
|
||||||
|
}
|
||||||
|
final envelope = ApiEnvelope.tryParse(response.data);
|
||||||
|
if (envelope == null || status >= 500) {
|
||||||
|
throw ApiNetworkException('响应异常:HTTP $status');
|
||||||
|
}
|
||||||
|
if (envelope.code == ApiCodes.ok) {
|
||||||
|
return envelope.data;
|
||||||
|
}
|
||||||
|
throw ApiBusinessException(code: envelope.code, message: envelope.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/// 服务端统一响应信封 `{code, message, data}` 的解析结果。
|
||||||
|
class ApiEnvelope {
|
||||||
|
const ApiEnvelope({required this.code, required this.message, this.data});
|
||||||
|
|
||||||
|
final int code;
|
||||||
|
final String message;
|
||||||
|
final Object? data;
|
||||||
|
|
||||||
|
/// 从响应体解析信封;结构不符(非 JSON 对象 / 无 int code)返回 null。
|
||||||
|
static ApiEnvelope? tryParse(Object? body) {
|
||||||
|
if (body is! Map) return null;
|
||||||
|
final code = body['code'];
|
||||||
|
if (code is! int) return null;
|
||||||
|
return ApiEnvelope(
|
||||||
|
code: code,
|
||||||
|
message: body['message'] is String ? body['message'] as String : '',
|
||||||
|
data: body['data'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/// 服务端错误信封 `{code, message, data}` 的业务码常量(接口契约冻结稿)。
|
||||||
|
abstract final class ApiCodes {
|
||||||
|
static const ok = 0;
|
||||||
|
|
||||||
|
/// 参数错误。
|
||||||
|
static const paramError = 40000;
|
||||||
|
|
||||||
|
/// 用户名或密码错误。
|
||||||
|
static const badCredentials = 40100;
|
||||||
|
|
||||||
|
/// access token 无效或过期。
|
||||||
|
static const accessTokenInvalid = 40101;
|
||||||
|
|
||||||
|
/// refresh token 已失效。
|
||||||
|
static const refreshTokenInvalid = 40102;
|
||||||
|
|
||||||
|
/// 用户名重复。
|
||||||
|
static const usernameTaken = 40900;
|
||||||
|
|
||||||
|
/// 手机号重复。
|
||||||
|
static const phoneTaken = 40901;
|
||||||
|
|
||||||
|
/// 登录失败次数过多,账号临时锁定(HTTP 423,见 openapi.yaml)。
|
||||||
|
static const loginLocked = 42300;
|
||||||
|
|
||||||
|
// ------ pets 域(契约 v1.2.0 冻结,M2 第二波定型 8 个)------
|
||||||
|
|
||||||
|
/// 对可见宠物无相应操作权限(viewer 写记录、caregiver/viewer 改档案)。
|
||||||
|
static const petAccessDenied = 40300;
|
||||||
|
|
||||||
|
/// 宠物不存在 / 已软删 / 与调用者无关系(防枚举,三种情况响应一致)。
|
||||||
|
static const petNotFound = 40401;
|
||||||
|
|
||||||
|
/// 记录不存在或记录所属宠物对调用者不可见(记录级防枚举)。
|
||||||
|
static const recordNotFound = 40402;
|
||||||
|
|
||||||
|
/// 乐观锁版本冲突(提交的 version 已过期;提醒的条件更新守卫落空同码)。
|
||||||
|
static const versionConflict = 40902;
|
||||||
|
|
||||||
|
/// 芯片号已被登记(跨用户唯一)。
|
||||||
|
static const microchipTaken = 40903;
|
||||||
|
|
||||||
|
/// 同宠物同疫苗同系列同剂次的非 cancelled 疫苗记录已存在。
|
||||||
|
static const vaccinationDoseExists = 40904;
|
||||||
|
|
||||||
|
/// 疫苗状态机 / 状态-日期规则违反(HTTP 422)。
|
||||||
|
static const vaccinationRuleViolation = 42201;
|
||||||
|
|
||||||
|
/// 提醒状态机 / 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 调用的类型化异常。页面按类型映射为三层错误呈现
|
||||||
|
/// (字段级 / 表单横幅 / SnackBar,见 12 号组装稿 §4),
|
||||||
|
/// 服务端原始 message 一律不直接透出给用户。
|
||||||
|
sealed class ApiException implements Exception {
|
||||||
|
const ApiException(this.message);
|
||||||
|
|
||||||
|
/// 服务端返回的原始 message(仅用于日志排查,不上屏)。
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 系统错误:超时、断网、5xx、响应无法解析。归瞬态层(SnackBar + 重试)。
|
||||||
|
final class ApiNetworkException extends ApiException {
|
||||||
|
const ApiNetworkException([super.message = '网络或服务不可用']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 业务错误:错误信封 code != 0(40000/40100/40900/40901 等)。
|
||||||
|
/// 领域层可按错误码细分子类型(见 features/pets/pet_exceptions.dart)。
|
||||||
|
class ApiBusinessException extends ApiException {
|
||||||
|
const ApiBusinessException({required this.code, required String message})
|
||||||
|
: super(message);
|
||||||
|
|
||||||
|
final int code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 限流(HTTP 429):映射为横幅「尝试次数过多,请稍后再试」。
|
||||||
|
final class ApiRateLimitException extends ApiException {
|
||||||
|
const ApiRateLimitException([super.message = '请求过于频繁']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话已失效:refresh token 失效(40102)或刷新重放后仍 401。
|
||||||
|
/// 抛出前本地会话已被清除,应用会自动回到登录页。
|
||||||
|
final class SessionExpiredException extends ApiException {
|
||||||
|
const SessionExpiredException([super.message = '会话已失效']);
|
||||||
|
}
|
||||||
@@ -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,66 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_envelope.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/auth_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
|
|
||||||
|
/// 单飞(single-flight)token 刷新器。
|
||||||
|
///
|
||||||
|
/// 并发触发的多个 401/40101 只发出一次 `POST /auth/refresh`,
|
||||||
|
/// 其余调用共享同一个进行中的刷新结果。
|
||||||
|
///
|
||||||
|
/// 清会话纪律(12 号组装稿 §7):只有服务端明确判定 refresh 失效
|
||||||
|
/// (40102 或 HTTP 401)才清除本地会话;网络失败、5xx 一律保留 token。
|
||||||
|
class TokenRefresher {
|
||||||
|
TokenRefresher({required this._dio, required this._session});
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
final SessionManager _session;
|
||||||
|
|
||||||
|
Future<void>? _inflight;
|
||||||
|
|
||||||
|
/// 刷新并轮换 token。成功后新 token 已写入 [SessionManager]。
|
||||||
|
///
|
||||||
|
/// 抛出 [SessionExpiredException](会话已被清除)或
|
||||||
|
/// [ApiNetworkException](token 保留,可重试)。
|
||||||
|
Future<void> refresh() {
|
||||||
|
return _inflight ??= _doRefresh().whenComplete(() => _inflight = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doRefresh() async {
|
||||||
|
await _session.loadFromStorage();
|
||||||
|
final refreshToken = _session.refreshToken;
|
||||||
|
if (refreshToken == null) {
|
||||||
|
await _session.clearSession();
|
||||||
|
throw const SessionExpiredException('本地无 refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
final Response<Object?> response;
|
||||||
|
try {
|
||||||
|
response = await _dio.post<Object?>(
|
||||||
|
'/api/v1/auth/refresh',
|
||||||
|
data: {'refreshToken': refreshToken},
|
||||||
|
);
|
||||||
|
} on DioException catch (error) {
|
||||||
|
throw ApiNetworkException('刷新请求失败:${error.type.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final envelope = ApiEnvelope.tryParse(response.data);
|
||||||
|
if (response.statusCode == 401 ||
|
||||||
|
envelope?.code == ApiCodes.refreshTokenInvalid) {
|
||||||
|
await _session.clearSession();
|
||||||
|
throw const SessionExpiredException('refresh token 已失效');
|
||||||
|
}
|
||||||
|
if (envelope != null &&
|
||||||
|
envelope.code == ApiCodes.ok &&
|
||||||
|
envelope.data is Map<String, dynamic>) {
|
||||||
|
await _session.updateTokens(
|
||||||
|
AuthTokens.fromJson(envelope.data! as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw ApiNetworkException(
|
||||||
|
'刷新响应异常:HTTP ${response.statusCode},code ${envelope?.code}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ abstract final class AppColors {
|
|||||||
/// 主文字。
|
/// 主文字。
|
||||||
static const ink = Color(0xFF3E2A1F);
|
static const ink = Color(0xFF3E2A1F);
|
||||||
|
|
||||||
|
/// 承载信息的次级文字(日期、元数据、字段名;正典 feed-caption 色,
|
||||||
|
/// 白底 6.59:1 达 AA)。muted 仅限占位符、禁用态、纯装饰(05 号规范 DEBT-2)。
|
||||||
|
static const inkSoft = Color(0xFF6B5A4A);
|
||||||
|
|
||||||
/// 次级文字、占位符。
|
/// 次级文字、占位符。
|
||||||
static const muted = Color(0xFF9C8977);
|
static const muted = Color(0xFF9C8977);
|
||||||
|
|
||||||
@@ -50,6 +54,10 @@ abstract final class AppColors {
|
|||||||
/// 校验错误文字、错误描边、失败提示(白底上约 5.4:1,AA 达标)。
|
/// 校验错误文字、错误描边、失败提示(白底上约 5.4:1,AA 达标)。
|
||||||
static const error = Color(0xFFD0342C);
|
static const error = Color(0xFFD0342C);
|
||||||
|
|
||||||
|
/// error 淡底(8% tint)上的文字(error 本身在自家淡底仅 4.44:1 贴线不过;
|
||||||
|
/// 与 primary→primaryStrong 同构加深,05 号规范 §2/D8)。
|
||||||
|
static const errorDark = Color(0xFFB02C25);
|
||||||
|
|
||||||
/// 品牌渐变:hero 区、头像环、装饰性大面积(不承载正文文字)。
|
/// 品牌渐变:hero 区、头像环、装饰性大面积(不承载正文文字)。
|
||||||
static const brandGradient = LinearGradient(
|
static const brandGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
@@ -160,6 +168,7 @@ ThemeData buildAppTheme() {
|
|||||||
borderSide: BorderSide(color: AppColors.error, width: 1.5),
|
borderSide: BorderSide(color: AppColors.error, width: 1.5),
|
||||||
),
|
),
|
||||||
errorStyle: TextStyle(color: AppColors.error, fontSize: 12),
|
errorStyle: TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
helperStyle: TextStyle(color: AppColors.muted, fontSize: 12),
|
||||||
),
|
),
|
||||||
snackBarTheme: const SnackBarThemeData(
|
snackBarTheme: const SnackBarThemeData(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class AppTextField extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
this.controller,
|
this.controller,
|
||||||
this.prefixIcon,
|
this.prefixIcon,
|
||||||
|
this.prefixText,
|
||||||
this.errorText,
|
this.errorText,
|
||||||
this.helperText,
|
this.helperText,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
@@ -25,6 +26,10 @@ class AppTextField extends StatefulWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final TextEditingController? controller;
|
final TextEditingController? controller;
|
||||||
final IconData? prefixIcon;
|
final IconData? prefixIcon;
|
||||||
|
|
||||||
|
/// 固定前缀文本(如手机号的 '+86 '),仅展示与拼接语义,不进输入值。
|
||||||
|
final String? prefixText;
|
||||||
|
|
||||||
final String? errorText;
|
final String? errorText;
|
||||||
final String? helperText;
|
final String? helperText;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
@@ -62,6 +67,7 @@ class _AppTextFieldState extends State<AppTextField> {
|
|||||||
labelText: widget.label,
|
labelText: widget.label,
|
||||||
errorText: widget.errorText,
|
errorText: widget.errorText,
|
||||||
helperText: widget.helperText,
|
helperText: widget.helperText,
|
||||||
|
prefixText: widget.prefixText,
|
||||||
prefixIcon: widget.prefixIcon == null
|
prefixIcon: widget.prefixIcon == null
|
||||||
? null
|
? null
|
||||||
: Icon(widget.prefixIcon, color: AppColors.muted),
|
: Icon(widget.prefixIcon, color: AppColors.muted),
|
||||||
|
|||||||
@@ -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,78 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// 空态插画区(05 号规范 §3.4):承载引导动作的空态,向上兼容现有
|
||||||
|
/// `EmptyState`(42 图标 + 一行 bodySmall 的纯提示版继续留用)。
|
||||||
|
///
|
||||||
|
/// 布局:112 圆形插画区(`surfaceTint` 底 + 56 `primary` 图标,大面积
|
||||||
|
/// 装饰用法)→ 标题 → 说明(12 `inkSoft` ≤2 行居中)→ 可选 CTA。
|
||||||
|
/// v1 插画用 Material 图标,正式素材待品牌侧供给后原位替换。
|
||||||
|
class EmptyStateIllustration extends StatelessWidget {
|
||||||
|
const EmptyStateIllustration({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
super.key,
|
||||||
|
this.description,
|
||||||
|
this.ctaLabel,
|
||||||
|
this.onCtaPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
|
||||||
|
/// CTA 文案;null 则不渲染按钮(如筛选后空态)。
|
||||||
|
final String? ctaLabel;
|
||||||
|
final VoidCallback? onCtaPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.surfaceTint,
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 56, color: AppColors.primary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (description != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
description!,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (ctaLabel != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
),
|
||||||
|
onPressed: onCtaPressed,
|
||||||
|
child: Text(ctaLabel!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,169 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
/// 头像尺寸档(05 号规范 §3.1)。
|
||||||
|
enum PetAvatarSize {
|
||||||
|
/// 96:档案页头部(收敛现 104 → 96,与表单页一致)。
|
||||||
|
xl(96),
|
||||||
|
|
||||||
|
/// 64:宠物列表卡。
|
||||||
|
lg(64),
|
||||||
|
|
||||||
|
/// 44:头部宠物切换器(最小触控目标)。
|
||||||
|
md(44),
|
||||||
|
|
||||||
|
/// 32:记录详情等行内。
|
||||||
|
sm(32);
|
||||||
|
|
||||||
|
const PetAvatarSize(this.dimension);
|
||||||
|
|
||||||
|
final double dimension;
|
||||||
|
|
||||||
|
/// 编辑徽标仅 xl / lg 档提供(05 §3.1)。
|
||||||
|
bool get supportsBadge => this == xl || this == lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宠物头像(05 号规范 §3.1):统一原档案页头部 / 编辑 sheet 各写一遍的
|
||||||
|
/// 头像实现。圆形裁切 + 3px `surface` 白描边 + 轻投影(正典
|
||||||
|
/// `.patbond-avatar` 规格),可选右下编辑徽标与品牌渐变环。
|
||||||
|
///
|
||||||
|
/// M2 首版不做头像上传(ADR-010 / D2-1 裁决),[url] 为 null 时渲染
|
||||||
|
/// 本地占位形态(`surfaceTint` 底 + `Icons.pets`);有图时经
|
||||||
|
/// [RemoteImage] 加载(loading / 失败兜底由其内置)。
|
||||||
|
class PetAvatar extends StatelessWidget {
|
||||||
|
const PetAvatar({
|
||||||
|
required this.size,
|
||||||
|
super.key,
|
||||||
|
this.url,
|
||||||
|
this.onTap,
|
||||||
|
this.showEditBadge = false,
|
||||||
|
this.ring = false,
|
||||||
|
this.enabled = true,
|
||||||
|
this.semanticLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetAvatarSize size;
|
||||||
|
|
||||||
|
/// 头像图 URL;null 为本地占位形态(M2 默认)。
|
||||||
|
final String? url;
|
||||||
|
|
||||||
|
/// 可点击时整体 [InkWell] 圆形 ripple。
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
/// 右下编辑徽标(`primaryStrong` 底 + 白 edit 图标,白/primaryStrong
|
||||||
|
/// 4.49:1 达非文字 3:1;修订现实现的 `primary` 底 2.75:1)。
|
||||||
|
final bool showEditBadge;
|
||||||
|
|
||||||
|
/// 2px `brandGradient` 外环(正典 story 环),仅作「当前选中」纯装饰指示。
|
||||||
|
final bool ring;
|
||||||
|
|
||||||
|
/// 禁用态 60% 不透明度(对齐 AppTextField 禁用惯例)。
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
final String? semanticLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dimension = size.dimension;
|
||||||
|
// 3px 白描边内的图面尺寸。
|
||||||
|
final imageDimension = dimension - 6;
|
||||||
|
|
||||||
|
Widget avatar = Container(
|
||||||
|
width: dimension,
|
||||||
|
height: dimension,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.surface,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x14000000), // rgba(0,0,0,0.08)
|
||||||
|
offset: Offset(0, 4),
|
||||||
|
blurRadius: 10,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
child: ClipOval(
|
||||||
|
child: url == null
|
||||||
|
? ColoredBox(
|
||||||
|
color: AppColors.surfaceTint,
|
||||||
|
child: Icon(
|
||||||
|
Icons.pets,
|
||||||
|
color: AppColors.muted,
|
||||||
|
size: imageDimension * 0.45,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RemoteImage(
|
||||||
|
url: url!,
|
||||||
|
width: imageDimension,
|
||||||
|
height: imageDimension,
|
||||||
|
borderRadius: BorderRadius.circular(imageDimension / 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ring) {
|
||||||
|
avatar = Container(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: AppColors.brandGradient,
|
||||||
|
),
|
||||||
|
child: avatar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showEditBadge && size.supportsBadge) {
|
||||||
|
avatar = Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
avatar,
|
||||||
|
const Positioned(right: 0, bottom: 0, child: _EditBadge()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onTap != null) {
|
||||||
|
avatar = InkWell(
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
child: avatar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
avatar = Opacity(opacity: 0.6, child: avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (semanticLabel != null) {
|
||||||
|
avatar = Semantics(
|
||||||
|
label: semanticLabel,
|
||||||
|
button: onTap != null,
|
||||||
|
child: avatar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 32 圆编辑徽标:`primaryStrong` 底 + 白 edit 15(05 §3.1 无障碍修订)。
|
||||||
|
class _EditBadge extends StatelessWidget {
|
||||||
|
const _EditBadge();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.primaryStrong,
|
||||||
|
border: Border.fromBorderSide(
|
||||||
|
BorderSide(color: AppColors.surface, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.edit, color: Colors.white, size: 15),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,144 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// M2 记录类型(05 号规范 §2 五种,「其他」为扩展兜底;
|
||||||
|
/// T2-14 为六类健康事件增补 feeding / grooming / measurement 三型,
|
||||||
|
/// 色族复用 05 §2 已审计的四个色对,仅图标与文案区分——对比度结论不变)。
|
||||||
|
enum RecordType {
|
||||||
|
weight,
|
||||||
|
vaccine,
|
||||||
|
deworming,
|
||||||
|
medical,
|
||||||
|
other,
|
||||||
|
feeding,
|
||||||
|
grooming,
|
||||||
|
measurement,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 类型 → 图标 + 三色(dot 底 8% 淡染基色 / 图标色 / 文字标签色)+ 文案的
|
||||||
|
/// 唯一映射出口(05 §3.2:映射只存在于本文件一处,杜绝散落硬编码)。
|
||||||
|
///
|
||||||
|
/// 对比度依据 05 §2 自查表:图标于 dot 底 4.16–7.39:1(非文字 ≥3:1)、
|
||||||
|
/// 文字于 dot 底 5.78–8.74:1(AA ≥4.5:1)均达标。类型必须以
|
||||||
|
/// 「图标 + 文字」双通道呈现,不得单靠颜色区分(色盲可辨性)。
|
||||||
|
class RecordTypeStyle {
|
||||||
|
const RecordTypeStyle({
|
||||||
|
required this.icon,
|
||||||
|
required this.baseColor,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.inkColor,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// dot 底基色(渲染时 `withAlpha(20)` 即 8% 淡染)。
|
||||||
|
final Color baseColor;
|
||||||
|
|
||||||
|
final Color iconColor;
|
||||||
|
|
||||||
|
/// 文字/标签色,供 TagPill、详情页复用。
|
||||||
|
final Color inkColor;
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 五类映射常量(05 §2 表逐行对应)。
|
||||||
|
const Map<RecordType, RecordTypeStyle> recordTypeStyles = {
|
||||||
|
RecordType.weight: RecordTypeStyle(
|
||||||
|
icon: Icons.monitor_weight_outlined,
|
||||||
|
baseColor: AppColors.primary,
|
||||||
|
iconColor: AppColors.primaryStrong,
|
||||||
|
inkColor: AppColors.primaryDark,
|
||||||
|
label: '体重',
|
||||||
|
),
|
||||||
|
RecordType.vaccine: RecordTypeStyle(
|
||||||
|
icon: Icons.vaccines_outlined,
|
||||||
|
baseColor: AppColors.success,
|
||||||
|
iconColor: AppColors.successInk,
|
||||||
|
inkColor: AppColors.successInk,
|
||||||
|
label: '疫苗',
|
||||||
|
),
|
||||||
|
RecordType.deworming: RecordTypeStyle(
|
||||||
|
icon: Icons.pest_control,
|
||||||
|
baseColor: AppColors.accent,
|
||||||
|
iconColor: AppColors.accentDark,
|
||||||
|
inkColor: AppColors.accentDark,
|
||||||
|
label: '驱虫',
|
||||||
|
),
|
||||||
|
RecordType.medical: RecordTypeStyle(
|
||||||
|
icon: Icons.medical_services_outlined,
|
||||||
|
baseColor: AppColors.error,
|
||||||
|
iconColor: AppColors.error,
|
||||||
|
inkColor: AppColors.errorDark,
|
||||||
|
label: '就医',
|
||||||
|
),
|
||||||
|
RecordType.other: RecordTypeStyle(
|
||||||
|
icon: Icons.sticky_note_2_outlined,
|
||||||
|
baseColor: AppColors.muted,
|
||||||
|
iconColor: AppColors.inkSoft,
|
||||||
|
inkColor: AppColors.inkSoft,
|
||||||
|
label: '其他',
|
||||||
|
),
|
||||||
|
// —— T2-14 健康事件增补(色族复用已审计色对)——
|
||||||
|
RecordType.feeding: RecordTypeStyle(
|
||||||
|
icon: Icons.restaurant_outlined,
|
||||||
|
baseColor: AppColors.success,
|
||||||
|
iconColor: AppColors.successInk,
|
||||||
|
inkColor: AppColors.successInk,
|
||||||
|
label: '喂养',
|
||||||
|
),
|
||||||
|
RecordType.grooming: RecordTypeStyle(
|
||||||
|
icon: Icons.content_cut,
|
||||||
|
baseColor: AppColors.accent,
|
||||||
|
iconColor: AppColors.accentDark,
|
||||||
|
inkColor: AppColors.accentDark,
|
||||||
|
label: '洗护',
|
||||||
|
),
|
||||||
|
RecordType.measurement: RecordTypeStyle(
|
||||||
|
icon: Icons.straighten_outlined,
|
||||||
|
baseColor: AppColors.primary,
|
||||||
|
iconColor: AppColors.primaryStrong,
|
||||||
|
inkColor: AppColors.primaryDark,
|
||||||
|
label: '测量',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 圆标尺寸档(05 §3.2)。图标尺寸 = dot 的 50%。
|
||||||
|
enum RecordTypeDotSize {
|
||||||
|
/// 24:表单类型选择器内。
|
||||||
|
sm(24),
|
||||||
|
|
||||||
|
/// 40:时间线条目。
|
||||||
|
md(40),
|
||||||
|
|
||||||
|
/// 56:记录详情页头。
|
||||||
|
lg(56);
|
||||||
|
|
||||||
|
const RecordTypeDotSize(this.dimension);
|
||||||
|
|
||||||
|
final double dimension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录类型圆标(05 号规范 §3.2):正圆淡染底 + 类型图标。
|
||||||
|
/// 无自身点击态(点击归属父容器)、无禁用态。
|
||||||
|
class RecordTypeDot extends StatelessWidget {
|
||||||
|
const RecordTypeDot({required this.type, required this.size, super.key});
|
||||||
|
|
||||||
|
final RecordType type;
|
||||||
|
final RecordTypeDotSize size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final style = recordTypeStyles[type]!;
|
||||||
|
return Container(
|
||||||
|
width: size.dimension,
|
||||||
|
height: size.dimension,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: style.baseColor.withAlpha(20),
|
||||||
|
),
|
||||||
|
child: Icon(style.icon, color: style.iconColor, size: size.dimension / 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,33 +82,6 @@ const initialPet = PetProfile(
|
|||||||
avatarUrl: petAvatar,
|
avatarUrl: petAvatar,
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialVaccines = VaccineRecord(
|
|
||||||
completedDoses: 2,
|
|
||||||
totalDoses: 3,
|
|
||||||
items: [
|
|
||||||
VaccineItem(
|
|
||||||
id: 'v1',
|
|
||||||
name: '犬五联 第1针',
|
|
||||||
status: VaccineStatus.completed,
|
|
||||||
date: '2025-05-12',
|
|
||||||
),
|
|
||||||
VaccineItem(
|
|
||||||
id: 'v2',
|
|
||||||
name: '狂犬疫苗',
|
|
||||||
status: VaccineStatus.completed,
|
|
||||||
date: '2025-06-15',
|
|
||||||
),
|
|
||||||
VaccineItem(
|
|
||||||
id: 'v3',
|
|
||||||
name: '犬五联 加强针',
|
|
||||||
status: VaccineStatus.pending,
|
|
||||||
date: '待定',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
reminderVaccine: '犬五联 加强针',
|
|
||||||
reminderDate: '2026-08-15',
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialPosts = [
|
const initialPosts = [
|
||||||
PostModel(
|
PostModel(
|
||||||
id: 'post1',
|
id: 'post1',
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/// 认证接口的响应模型(接口契约冻结稿,字段名与后端一致)。
|
||||||
|
class AuthTokens {
|
||||||
|
const AuthTokens({
|
||||||
|
required this.userId,
|
||||||
|
required this.tokenType,
|
||||||
|
required this.accessToken,
|
||||||
|
required this.accessTokenExpiresAt,
|
||||||
|
required this.refreshToken,
|
||||||
|
required this.refreshTokenExpiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AuthTokens.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AuthTokens(
|
||||||
|
userId: json['userId'] as String,
|
||||||
|
tokenType: json['tokenType'] as String,
|
||||||
|
accessToken: json['accessToken'] as String,
|
||||||
|
accessTokenExpiresAt: DateTime.parse(
|
||||||
|
json['accessTokenExpiresAt'] as String,
|
||||||
|
),
|
||||||
|
refreshToken: json['refreshToken'] as String,
|
||||||
|
refreshTokenExpiresAt: DateTime.parse(
|
||||||
|
json['refreshTokenExpiresAt'] as String,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final String tokenType;
|
||||||
|
final String accessToken;
|
||||||
|
final DateTime accessTokenExpiresAt;
|
||||||
|
final String refreshToken;
|
||||||
|
final DateTime refreshTokenExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/me` 的用户资料。
|
||||||
|
class UserProfile {
|
||||||
|
const UserProfile({
|
||||||
|
required this.userId,
|
||||||
|
required this.username,
|
||||||
|
required this.phone,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UserProfile.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserProfile(
|
||||||
|
userId: json['userId'] as String,
|
||||||
|
username: json['username'] as String,
|
||||||
|
// 服务端可返回 null(历史数据或未来第三方注册),不得非空强转。
|
||||||
|
phone: json['phone'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final String username;
|
||||||
|
final String? phone;
|
||||||
|
final DateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'package:patbond_flutter/analytics/analytics_service.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:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// Splash 会话恢复的裁决结果。状态切换由 Splash 在最短停留时间后执行。
|
||||||
|
enum SessionRestoreResult { authenticated, noSession }
|
||||||
|
|
||||||
|
/// 认证仓库接口(页面依赖此抽象,widget 测试注入假实现)。
|
||||||
|
abstract class AuthRepository {
|
||||||
|
Future<void> login({required String username, required String password});
|
||||||
|
|
||||||
|
Future<void> register({
|
||||||
|
required String username,
|
||||||
|
required String phone,
|
||||||
|
required String password,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 撤销当前会话并清除本地凭证。服务端调用失败也保证本地清除。
|
||||||
|
Future<void> logout();
|
||||||
|
|
||||||
|
/// 启动恢复:本地有 refresh token 则静默刷新。
|
||||||
|
/// 网络失败抛 [ApiNetworkException](token 保留);
|
||||||
|
/// refresh 失效抛 [SessionExpiredException](会话已清)。
|
||||||
|
Future<SessionRestoreResult> restoreSession();
|
||||||
|
|
||||||
|
Future<UserProfile> me();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiAuthRepository implements AuthRepository {
|
||||||
|
ApiAuthRepository({
|
||||||
|
required this._api,
|
||||||
|
required this._session,
|
||||||
|
required this._refresher,
|
||||||
|
this._analytics,
|
||||||
|
this._uuid = const Uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
final ApiClient _api;
|
||||||
|
final SessionManager _session;
|
||||||
|
final TokenRefresher _refresher;
|
||||||
|
final AnalyticsService? _analytics;
|
||||||
|
final Uuid _uuid;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> login({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
try {
|
||||||
|
final data = await _api.request(
|
||||||
|
'/api/v1/auth/login',
|
||||||
|
body: {'username': username, 'password': password},
|
||||||
|
);
|
||||||
|
await _session.saveSession(
|
||||||
|
AuthTokens.fromJson(data! as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
_analytics?.identify(_session.userId!);
|
||||||
|
_analytics?.trackEvent('auth_login_succeeded', {
|
||||||
|
'identifierType': 'username',
|
||||||
|
'durationMs': DateTime.now().difference(startTime).inMilliseconds,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
_analytics?.trackEvent('auth_login_failed', {
|
||||||
|
'identifierType': 'username',
|
||||||
|
'failureReason': error.toString(),
|
||||||
|
});
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> register({
|
||||||
|
required String username,
|
||||||
|
required String phone,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
try {
|
||||||
|
final data = await _api.request(
|
||||||
|
'/api/v1/auth/register',
|
||||||
|
body: {'username': username, 'phone': phone, 'password': password},
|
||||||
|
// 每次提交一个幂等键;token 刷新后的自动重放沿用同一个键。
|
||||||
|
headers: {'Idempotency-Key': _uuid.v4()},
|
||||||
|
);
|
||||||
|
await _session.saveSession(
|
||||||
|
AuthTokens.fromJson(data! as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
_analytics?.identify(_session.userId!);
|
||||||
|
_analytics?.trackEvent('auth_register_succeeded', {
|
||||||
|
'durationMs': DateTime.now().difference(startTime).inMilliseconds,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
_analytics?.trackEvent('auth_register_failed', {
|
||||||
|
'failureReason': error.toString(),
|
||||||
|
});
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> logout() async {
|
||||||
|
final refreshToken = _session.refreshToken;
|
||||||
|
try {
|
||||||
|
await _api.request(
|
||||||
|
'/api/v1/auth/logout',
|
||||||
|
body: {'refreshToken': refreshToken},
|
||||||
|
requiresAuth: true,
|
||||||
|
);
|
||||||
|
} on ApiException {
|
||||||
|
// 服务端撤销失败不阻塞本地登出;refresh 侧最终会自然过期。
|
||||||
|
} finally {
|
||||||
|
await _session.clearSession();
|
||||||
|
_analytics?.reset();
|
||||||
|
_analytics?.trackEvent('auth_logout', {'serverRevoked': true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SessionRestoreResult> restoreSession() async {
|
||||||
|
await _session.loadFromStorage();
|
||||||
|
if (_session.refreshToken == null) {
|
||||||
|
return SessionRestoreResult.noSession;
|
||||||
|
}
|
||||||
|
await _refresher.refresh();
|
||||||
|
return SessionRestoreResult.authenticated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserProfile> me() async {
|
||||||
|
final data = await _api.request(
|
||||||
|
'/api/v1/me',
|
||||||
|
method: 'GET',
|
||||||
|
requiresAuth: true,
|
||||||
|
);
|
||||||
|
return UserProfile.fromJson(data! as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/auth_scaffold.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/brand_mark.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/auth_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/register_page.dart';
|
||||||
|
|
||||||
|
enum _Field { account, password }
|
||||||
|
|
||||||
|
/// 登录页(12 号组装稿 §5)。登录成功后由认证状态机切入主壳。
|
||||||
|
class LoginPage extends StatefulWidget {
|
||||||
|
const LoginPage({required this.authRepository, super.key});
|
||||||
|
|
||||||
|
final AuthRepository authRepository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginPage> createState() => _LoginPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPageState extends State<LoginPage> {
|
||||||
|
final _accountCtrl = TextEditingController();
|
||||||
|
final _passwordCtrl = TextEditingController();
|
||||||
|
String? _accountError;
|
||||||
|
String? _passwordError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_accountCtrl.dispose();
|
||||||
|
_passwordCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateAccount() =>
|
||||||
|
_accountCtrl.text.trim().isEmpty ? '请输入用户名或手机号' : null;
|
||||||
|
|
||||||
|
String? _validatePassword() =>
|
||||||
|
_passwordCtrl.text.trim().isEmpty ? '请输入密码' : null;
|
||||||
|
|
||||||
|
void _validateAccountOnBlur() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _accountError = _validateAccount());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validatePasswordOnBlur() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _passwordError = _validatePassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字段变更即清除本字段错误与表单级横幅。
|
||||||
|
void _clearErrors({required _Field field}) {
|
||||||
|
if (_formError == null &&
|
||||||
|
(field == _Field.account ? _accountError : _passwordError) == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_formError = null;
|
||||||
|
if (field == _Field.account) _accountError = null;
|
||||||
|
if (field == _Field.password) _passwordError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showNetworkSnackBar() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
final accountError = _validateAccount();
|
||||||
|
final passwordError = _validatePassword();
|
||||||
|
if (accountError != null || passwordError != null) {
|
||||||
|
setState(() {
|
||||||
|
_accountError = accountError;
|
||||||
|
_passwordError = passwordError;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await widget.authRepository.login(
|
||||||
|
username: _accountCtrl.text.trim(),
|
||||||
|
password: _passwordCtrl.text.trim(),
|
||||||
|
);
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
|
// 认证状态机随 saveSession 切换,根路由 300ms fade 进主壳。
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError(switch (error.code) {
|
||||||
|
ApiCodes.badCredentials => '用户名或密码错误',
|
||||||
|
ApiCodes.loginLocked => '尝试次数过多,账号已临时锁定,请稍后再试',
|
||||||
|
_ => '登录失败,请稍后重试',
|
||||||
|
});
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('尝试次数过多,请稍后再试');
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showNetworkSnackBar();
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 登录请求不带会话,理论不可达;静默留在登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goRegister() {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
fadePageRoute<void>(
|
||||||
|
RegisterPage(authRepository: widget.authRepository),
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.register.pageName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AuthScaffold(
|
||||||
|
child: AutofillGroup(
|
||||||
|
child: Column(
|
||||||
|
// AuthScaffold 内禁用 Spacer,垂直居中用 mainAxisAlignment(§4 总则)。
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
const BrandMark(),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
Focus(
|
||||||
|
onFocusChange: (hasFocus) {
|
||||||
|
if (!hasFocus) _validateAccountOnBlur();
|
||||||
|
},
|
||||||
|
child: AppTextField(
|
||||||
|
label: '用户名 / 手机号',
|
||||||
|
controller: _accountCtrl,
|
||||||
|
prefixIcon: Icons.person_outline_rounded,
|
||||||
|
errorText: _accountError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autofillHints: const [AutofillHints.username],
|
||||||
|
onChanged: (_) => _clearErrors(field: _Field.account),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Focus(
|
||||||
|
onFocusChange: (hasFocus) {
|
||||||
|
if (!hasFocus) _validatePasswordOnBlur();
|
||||||
|
},
|
||||||
|
child: AppTextField(
|
||||||
|
label: '密码',
|
||||||
|
controller: _passwordCtrl,
|
||||||
|
prefixIcon: Icons.lock_outline_rounded,
|
||||||
|
obscurable: true,
|
||||||
|
errorText: _passwordError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
onChanged: (_) => _clearErrors(field: _Field.password),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: '登录',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'还没有账号?',
|
||||||
|
style: TextStyle(color: AppColors.muted, fontSize: 14),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.primaryStrong,
|
||||||
|
minimumSize: const Size(44, 44),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: _submitting ? null : _goRegister,
|
||||||
|
child: const Text('立即注册'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
// 协议行:v1 协议页未就绪,整行不渲染(组装稿 §5)。
|
||||||
|
// 预留区(短信验证码/第三方登录):不渲染任何占位(ADR-004)。
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/auth_scaffold.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/auth_repository.dart';
|
||||||
|
|
||||||
|
enum _Field { username, phone, password, confirm }
|
||||||
|
|
||||||
|
/// 注册页(12 号组装稿 §6)。字段按 ADR-004:用户名 + 手机号 + 密码 + 确认密码;
|
||||||
|
/// 短信验证码行与第三方登录预留区一律不渲染。注册成功即建立会话直接进首页。
|
||||||
|
class RegisterPage extends StatefulWidget {
|
||||||
|
const RegisterPage({required this.authRepository, super.key});
|
||||||
|
|
||||||
|
final AuthRepository authRepository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RegisterPage> createState() => _RegisterPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegisterPageState extends State<RegisterPage> {
|
||||||
|
static final _usernamePattern = RegExp(r'^[A-Za-z][A-Za-z0-9_]{2,19}$');
|
||||||
|
static final _phonePattern = RegExp(r'^1\d{10}$');
|
||||||
|
|
||||||
|
final _usernameCtrl = TextEditingController();
|
||||||
|
final _phoneCtrl = TextEditingController();
|
||||||
|
final _passwordCtrl = TextEditingController();
|
||||||
|
final _confirmCtrl = TextEditingController();
|
||||||
|
String? _usernameError;
|
||||||
|
String? _phoneError;
|
||||||
|
String? _passwordError;
|
||||||
|
String? _confirmError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_usernameCtrl.dispose();
|
||||||
|
_phoneCtrl.dispose();
|
||||||
|
_passwordCtrl.dispose();
|
||||||
|
_confirmCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateUsernameValue() {
|
||||||
|
final value = _usernameCtrl.text.trim();
|
||||||
|
if (value.isEmpty) return '请输入用户名';
|
||||||
|
if (!_usernamePattern.hasMatch(value)) {
|
||||||
|
return '用户名需 3–20 位,字母开头,可含数字和下划线';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validatePhoneValue() {
|
||||||
|
final value = _phoneCtrl.text.trim();
|
||||||
|
if (value.isEmpty) return '请输入手机号';
|
||||||
|
if (!_phonePattern.hasMatch(value)) return '请输入正确的 11 位手机号';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validatePasswordValue() {
|
||||||
|
final value = _passwordCtrl.text;
|
||||||
|
final lengthOk = value.length >= 8 && value.length <= 32;
|
||||||
|
final hasLetter = value.contains(RegExp('[A-Za-z]'));
|
||||||
|
final hasDigit = value.contains(RegExp('[0-9]'));
|
||||||
|
if (!lengthOk || !hasLetter || !hasDigit) {
|
||||||
|
return '密码需 8–32 位,且同时包含字母和数字';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateConfirmValue() =>
|
||||||
|
_confirmCtrl.text == _passwordCtrl.text ? null : '两次输入的密码不一致';
|
||||||
|
|
||||||
|
void _validateOnBlur(_Field field) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
switch (field) {
|
||||||
|
case _Field.username:
|
||||||
|
_usernameError = _validateUsernameValue();
|
||||||
|
case _Field.phone:
|
||||||
|
_phoneError = _validatePhoneValue();
|
||||||
|
case _Field.password:
|
||||||
|
_passwordError = _validatePasswordValue();
|
||||||
|
case _Field.confirm:
|
||||||
|
_confirmError = _validateConfirmValue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字段变更清除本字段错误与横幅;密码变更时若确认密码已有值,
|
||||||
|
/// 同步重校验一致性(组装稿 §6)。
|
||||||
|
void _clearError(_Field field) {
|
||||||
|
setState(() {
|
||||||
|
_formError = null;
|
||||||
|
switch (field) {
|
||||||
|
case _Field.username:
|
||||||
|
_usernameError = null;
|
||||||
|
case _Field.phone:
|
||||||
|
_phoneError = null;
|
||||||
|
case _Field.password:
|
||||||
|
_passwordError = null;
|
||||||
|
if (_confirmCtrl.text.isNotEmpty) {
|
||||||
|
_confirmError = _validateConfirmValue();
|
||||||
|
}
|
||||||
|
case _Field.confirm:
|
||||||
|
_confirmError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
final usernameError = _validateUsernameValue();
|
||||||
|
final phoneError = _validatePhoneValue();
|
||||||
|
final passwordError = _validatePasswordValue();
|
||||||
|
final confirmError = _validateConfirmValue();
|
||||||
|
if (usernameError != null ||
|
||||||
|
phoneError != null ||
|
||||||
|
passwordError != null ||
|
||||||
|
confirmError != null) {
|
||||||
|
setState(() {
|
||||||
|
_usernameError = usernameError;
|
||||||
|
_phoneError = phoneError;
|
||||||
|
_passwordError = passwordError;
|
||||||
|
_confirmError = confirmError;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await widget.authRepository.register(
|
||||||
|
username: _usernameCtrl.text.trim(),
|
||||||
|
phone: '+86${_phoneCtrl.text.trim()}',
|
||||||
|
password: _passwordCtrl.text,
|
||||||
|
);
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
|
if (!mounted) return;
|
||||||
|
// 会话已建立,根路由切主壳;弹掉注册页露出新首页,不回登录页。
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
switch (error.code) {
|
||||||
|
case ApiCodes.usernameTaken:
|
||||||
|
setState(() => _usernameError = '该用户名已被使用');
|
||||||
|
case ApiCodes.phoneTaken:
|
||||||
|
setState(() => _phoneError = '该手机号已注册,可直接登录');
|
||||||
|
default:
|
||||||
|
_showFormError('注册失败,请稍后重试');
|
||||||
|
}
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('尝试次数过多,请稍后再试');
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 注册请求不带会话,理论不可达。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _blurValidated(_Field field, Widget child) {
|
||||||
|
return Focus(
|
||||||
|
onFocusChange: (hasFocus) {
|
||||||
|
if (!hasFocus) _validateOnBlur(field);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AuthScaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
),
|
||||||
|
child: AutofillGroup(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('创建账号', style: Theme.of(context).textTheme.headlineSmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'加入 Patbond,记录毛孩子的每一天',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
_blurValidated(
|
||||||
|
_Field.username,
|
||||||
|
AppTextField(
|
||||||
|
label: '用户名',
|
||||||
|
controller: _usernameCtrl,
|
||||||
|
prefixIcon: Icons.person_outline_rounded,
|
||||||
|
errorText: _usernameError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autofillHints: const [AutofillHints.newUsername],
|
||||||
|
onChanged: (_) => _clearError(_Field.username),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_blurValidated(
|
||||||
|
_Field.phone,
|
||||||
|
AppTextField(
|
||||||
|
label: '手机号',
|
||||||
|
controller: _phoneCtrl,
|
||||||
|
prefixIcon: Icons.phone_iphone_rounded,
|
||||||
|
prefixText: '+86 ',
|
||||||
|
errorText: _phoneError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autofillHints: const [AutofillHints.telephoneNumber],
|
||||||
|
onChanged: (_) => _clearError(_Field.phone),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_blurValidated(
|
||||||
|
_Field.password,
|
||||||
|
AppTextField(
|
||||||
|
label: '密码',
|
||||||
|
controller: _passwordCtrl,
|
||||||
|
prefixIcon: Icons.lock_outline_rounded,
|
||||||
|
obscurable: true,
|
||||||
|
errorText: _passwordError,
|
||||||
|
helperText: '密码 8–32 位,需包含字母和数字',
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
|
onChanged: (_) => _clearError(_Field.password),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_blurValidated(
|
||||||
|
_Field.confirm,
|
||||||
|
AppTextField(
|
||||||
|
label: '确认密码',
|
||||||
|
controller: _confirmCtrl,
|
||||||
|
prefixIcon: Icons.lock_outline_rounded,
|
||||||
|
obscurable: true,
|
||||||
|
errorText: _confirmError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
|
onChanged: (_) => _clearError(_Field.confirm),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
PrimaryButton(
|
||||||
|
label: '注册',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'已有账号?',
|
||||||
|
style: TextStyle(color: AppColors.muted, fontSize: 14),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.primaryStrong,
|
||||||
|
minimumSize: const Size(44, 44),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: _submitting
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('直接登录'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/auth_models.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// 认证状态机:unknown(启动恢复中,停留 Splash)→ authenticated / unauthenticated。
|
||||||
|
enum AuthStatus { unknown, authenticated, unauthenticated }
|
||||||
|
|
||||||
|
/// token 持久化抽象。生产实现走系统安全存储(Keychain / Keystore),
|
||||||
|
/// 测试注入内存实现。token 绝不写入 SharedPreferences(开发计划 §4.2)。
|
||||||
|
abstract class TokenStore {
|
||||||
|
Future<String?> read(String key);
|
||||||
|
Future<void> write(String key, String value);
|
||||||
|
Future<void> delete(String key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于 flutter_secure_storage 的生产实现。
|
||||||
|
class SecureTokenStore implements TokenStore {
|
||||||
|
const SecureTokenStore([this._storage = const FlutterSecureStorage()]);
|
||||||
|
|
||||||
|
final FlutterSecureStorage _storage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read(String key) => _storage.read(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, String value) =>
|
||||||
|
_storage.write(key: key, value: value);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String key) => _storage.delete(key: key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话管理:token 的内存副本 + 安全存储持久化 + 认证状态广播。
|
||||||
|
///
|
||||||
|
/// 状态变更只通过 [saveSession](登录/注册成功)、[markAuthenticated] /
|
||||||
|
/// [markUnauthenticated](Splash 恢复裁决)与 [clearSession](登出/会话失效)。
|
||||||
|
/// TokenRefresher 轮换 token 用 [updateTokens],不改状态——
|
||||||
|
/// Splash 需要在最短停留时间后才切页(12 号组装稿 §7)。
|
||||||
|
class SessionManager extends ChangeNotifier {
|
||||||
|
SessionManager({required this._store, this._uuid = const Uuid()});
|
||||||
|
|
||||||
|
static const _accessTokenKey = 'patbond_access_token';
|
||||||
|
static const _refreshTokenKey = 'patbond_refresh_token';
|
||||||
|
static const _accessExpiresKey = 'patbond_access_token_expires_at';
|
||||||
|
static const _refreshExpiresKey = 'patbond_refresh_token_expires_at';
|
||||||
|
static const _userIdKey = 'patbond_user_id';
|
||||||
|
static const _deviceIdKey = 'patbond_device_id';
|
||||||
|
|
||||||
|
final TokenStore _store;
|
||||||
|
final Uuid _uuid;
|
||||||
|
|
||||||
|
AuthStatus _status = AuthStatus.unknown;
|
||||||
|
String? _accessToken;
|
||||||
|
String? _refreshToken;
|
||||||
|
String? _userId;
|
||||||
|
String? _deviceId;
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
AuthStatus get status => _status;
|
||||||
|
String? get accessToken => _accessToken;
|
||||||
|
String? get refreshToken => _refreshToken;
|
||||||
|
String? get userId => _userId;
|
||||||
|
|
||||||
|
/// 设备标识:首次生成 UUID 并持久化,跨会话稳定。
|
||||||
|
String? get deviceId => _deviceId;
|
||||||
|
|
||||||
|
/// 从安全存储载入 token 到内存(幂等)。存储读取失败按无会话处理,
|
||||||
|
/// 不阻塞启动。
|
||||||
|
Future<void> loadFromStorage() async {
|
||||||
|
if (_loaded) return;
|
||||||
|
_accessToken = await _readOrNull(_accessTokenKey);
|
||||||
|
_refreshToken = await _readOrNull(_refreshTokenKey);
|
||||||
|
_userId = await _readOrNull(_userIdKey);
|
||||||
|
_deviceId = await _readOrNull(_deviceIdKey);
|
||||||
|
if (_deviceId == null) {
|
||||||
|
_deviceId = _uuid.v4();
|
||||||
|
await _writeQuietly(_deviceIdKey, _deviceId!);
|
||||||
|
}
|
||||||
|
_loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录/注册成功:持久化整套 token 并进入已认证态。
|
||||||
|
Future<void> saveSession(AuthTokens tokens) async {
|
||||||
|
await updateTokens(tokens);
|
||||||
|
_status = AuthStatus.authenticated;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅写入/轮换 token,不改变认证状态(刷新流程用)。
|
||||||
|
Future<void> updateTokens(AuthTokens tokens) async {
|
||||||
|
_accessToken = tokens.accessToken;
|
||||||
|
_refreshToken = tokens.refreshToken;
|
||||||
|
_userId = tokens.userId;
|
||||||
|
_loaded = true;
|
||||||
|
await _writeQuietly(_accessTokenKey, tokens.accessToken);
|
||||||
|
await _writeQuietly(_refreshTokenKey, tokens.refreshToken);
|
||||||
|
await _writeQuietly(
|
||||||
|
_accessExpiresKey,
|
||||||
|
tokens.accessTokenExpiresAt.toIso8601String(),
|
||||||
|
);
|
||||||
|
await _writeQuietly(
|
||||||
|
_refreshExpiresKey,
|
||||||
|
tokens.refreshTokenExpiresAt.toIso8601String(),
|
||||||
|
);
|
||||||
|
await _writeQuietly(_userIdKey, tokens.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splash 恢复成功后的显式切态。
|
||||||
|
void markAuthenticated() {
|
||||||
|
_status = AuthStatus.authenticated;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无本地会话或用户选择改用账号登录。
|
||||||
|
void markUnauthenticated() {
|
||||||
|
_status = AuthStatus.unauthenticated;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除会话(登出 / refresh 失效)。设备标识保留。
|
||||||
|
Future<void> clearSession() async {
|
||||||
|
_accessToken = null;
|
||||||
|
_refreshToken = null;
|
||||||
|
_userId = null;
|
||||||
|
_status = AuthStatus.unauthenticated;
|
||||||
|
notifyListeners();
|
||||||
|
for (final key in const [
|
||||||
|
_accessTokenKey,
|
||||||
|
_refreshTokenKey,
|
||||||
|
_accessExpiresKey,
|
||||||
|
_refreshExpiresKey,
|
||||||
|
_userIdKey,
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
await _store.delete(key);
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('清除会话存储失败($key):$error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _readOrNull(String key) async {
|
||||||
|
try {
|
||||||
|
return await _store.read(key);
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('读取安全存储失败($key):$error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeQuietly(String key, String value) async {
|
||||||
|
try {
|
||||||
|
await _store.write(key, value);
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('写入安全存储失败($key):$error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
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/brand_mark.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/auth_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
|
|
||||||
|
enum _SplashState { checking, failed }
|
||||||
|
|
||||||
|
/// 启动 Splash(12 号组装稿 §7):静默恢复会话。
|
||||||
|
///
|
||||||
|
/// 流程约束:最短停留 500ms;刷新超时 5s;spinner 等待超过 300ms 才出现;
|
||||||
|
/// 网络失败不清除本地 refresh token(只有服务端明确 401/40102 才清)。
|
||||||
|
class SplashPage extends StatefulWidget {
|
||||||
|
const SplashPage({
|
||||||
|
required this.authRepository,
|
||||||
|
required this.sessionManager,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AuthRepository authRepository;
|
||||||
|
final SessionManager sessionManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SplashPage> createState() => _SplashPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SplashPageState extends State<SplashPage> {
|
||||||
|
_SplashState _state = _SplashState.checking;
|
||||||
|
bool _showSpinner = false;
|
||||||
|
Timer? _spinnerTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_spinnerTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restore() async {
|
||||||
|
_spinnerTimer?.cancel();
|
||||||
|
_spinnerTimer = Timer(const Duration(milliseconds: 300), () {
|
||||||
|
if (mounted) setState(() => _showSpinner = true);
|
||||||
|
});
|
||||||
|
final minStay = Future<void>.delayed(const Duration(milliseconds: 500));
|
||||||
|
try {
|
||||||
|
final result = await widget.authRepository.restoreSession().timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
);
|
||||||
|
await minStay;
|
||||||
|
if (!mounted) return;
|
||||||
|
switch (result) {
|
||||||
|
case SessionRestoreResult.authenticated:
|
||||||
|
widget.sessionManager.markAuthenticated();
|
||||||
|
case SessionRestoreResult.noSession:
|
||||||
|
widget.sessionManager.markUnauthenticated();
|
||||||
|
}
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// refresh 已被服务端判失效,会话已清、状态机已切换,无需处理。
|
||||||
|
} catch (_) {
|
||||||
|
// 网络失败 / 超时:token 保留,转错误态供重试。
|
||||||
|
await minStay;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_state = _SplashState.failed;
|
||||||
|
_showSpinner = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _retryRefresh() {
|
||||||
|
setState(() {
|
||||||
|
_state = _SplashState.checking;
|
||||||
|
_showSpinner = false;
|
||||||
|
});
|
||||||
|
_restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearCredentialsAndGoLogin() async {
|
||||||
|
// 逃生通道:放弃本地凭证,改用账号登录。
|
||||||
|
await widget.sessionManager.clearSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.canvas,
|
||||||
|
body: Center(
|
||||||
|
child: _state == _SplashState.checking
|
||||||
|
? Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const BrandMark(),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
// 占位保高度,spinner 出现时不跳动。
|
||||||
|
SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: _showSpinner
|
||||||
|
? const CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: AppColors.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const BrandMark(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'网络连接失败,无法恢复登录',
|
||||||
|
style: TextStyle(color: AppColors.ink, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size(120, 44),
|
||||||
|
foregroundColor: AppColors.primaryStrong,
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
onPressed: _retryRefresh,
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.primaryStrong,
|
||||||
|
minimumSize: const Size(44, 44),
|
||||||
|
),
|
||||||
|
onPressed: _clearCredentialsAndGoLogin,
|
||||||
|
child: const Text('改用账号登录'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/toggle_sync.dart';
|
||||||
|
|
||||||
|
/// Feed 首屏四态(同 pets 先例)。
|
||||||
|
enum FeedPhase { initial, loading, ready, error }
|
||||||
|
|
||||||
|
/// 尾部加载更多三态(游标累积流新增,pets 无此并发点)。
|
||||||
|
enum LoadMorePhase { idle, loading, error }
|
||||||
|
|
||||||
|
/// community feature 状态控制器(03 号评估 §2 分层:
|
||||||
|
/// Page/Widget → CommunityController → CommunityRepository → ApiClient)。
|
||||||
|
///
|
||||||
|
/// Tab 级单例(app.dart 装配注入主壳):Feed 是游标累积流,且详情页与
|
||||||
|
/// 首页共享同一份帖子内存副本(点赞状态跨页一致),不做页面级 state。
|
||||||
|
/// 评论列表只属详情页,按「页面级状态按页自建」纪律经 [repository]
|
||||||
|
/// 自取,不膨胀本控制器。服务端是唯一事实来源,内存副本仅作展示缓存。
|
||||||
|
class CommunityController extends ChangeNotifier {
|
||||||
|
CommunityController({required this._repository, this._interactionAnalytics}) {
|
||||||
|
_likeSync = ToggleSync(
|
||||||
|
read: (id) {
|
||||||
|
final post = _postCache[id];
|
||||||
|
if (post != null) {
|
||||||
|
return ToggleReading(active: post.likedByMe, count: post.likeCount);
|
||||||
|
}
|
||||||
|
final card = _cardOrNull(id);
|
||||||
|
if (card == null) return null;
|
||||||
|
return ToggleReading(active: card.likedByMe, count: card.likeCount);
|
||||||
|
},
|
||||||
|
write: (id, active, count) =>
|
||||||
|
_writeInteraction(id, likedByMe: active, likeCount: count),
|
||||||
|
send: (id, target) async {
|
||||||
|
final state = target
|
||||||
|
? await _repository.likePost(id)
|
||||||
|
: await _repository.unlikePost(id);
|
||||||
|
// 成功响应后上报(06 §1.4 口径;乐观翻转与失败均不报)。
|
||||||
|
final source = _likeSources[id] ?? InteractionSource.feed;
|
||||||
|
target
|
||||||
|
? _interactionAnalytics?.postLiked(source: source)
|
||||||
|
: _interactionAnalytics?.postUnliked(source: source);
|
||||||
|
return ToggleOutcome(active: state.liked, count: state.likeCount);
|
||||||
|
},
|
||||||
|
generation: () => _generation,
|
||||||
|
onError: _onToggleError,
|
||||||
|
);
|
||||||
|
_bookmarkSync = ToggleSync(
|
||||||
|
read: (id) {
|
||||||
|
final post = _postCache[id];
|
||||||
|
if (post != null) {
|
||||||
|
return ToggleReading(
|
||||||
|
active: post.bookmarkedByMe,
|
||||||
|
count: post.bookmarkCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final card = _cardOrNull(id);
|
||||||
|
if (card == null) return null;
|
||||||
|
return ToggleReading(
|
||||||
|
active: card.bookmarkedByMe,
|
||||||
|
count: card.bookmarkCount,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
write: (id, active, count) =>
|
||||||
|
_writeInteraction(id, bookmarkedByMe: active, bookmarkCount: count),
|
||||||
|
send: (id, target) async {
|
||||||
|
final state = target
|
||||||
|
? await _repository.bookmarkPost(id)
|
||||||
|
: await _repository.unbookmarkPost(id);
|
||||||
|
final source = _bookmarkSources[id] ?? InteractionSource.feed;
|
||||||
|
target
|
||||||
|
? _interactionAnalytics?.postFavorited(source: source)
|
||||||
|
: _interactionAnalytics?.postUnfavorited(source: source);
|
||||||
|
return ToggleOutcome(
|
||||||
|
active: state.bookmarked,
|
||||||
|
count: state.bookmarkCount,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
generation: () => _generation,
|
||||||
|
onError: _onToggleError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final CommunityRepository _repository;
|
||||||
|
final CommunityInteractionAnalytics? _interactionAnalytics;
|
||||||
|
|
||||||
|
/// 各帖最近一次 toggle 的触点来源(Feed 卡片 / 详情页共享同一实例,
|
||||||
|
/// 成功响应上报时按发起触点归因)。
|
||||||
|
final Map<String, InteractionSource> _likeSources = {};
|
||||||
|
final Map<String, InteractionSource> _bookmarkSources = {};
|
||||||
|
|
||||||
|
/// 页面级状态(评论列表、我的帖子、收藏页等)按页直接经仓库取数。
|
||||||
|
CommunityRepository get repository => _repository;
|
||||||
|
|
||||||
|
late final ToggleSync _likeSync;
|
||||||
|
late final ToggleSync _bookmarkSync;
|
||||||
|
|
||||||
|
FeedPhase _phase = FeedPhase.initial;
|
||||||
|
LoadMorePhase _loadMorePhase = LoadMorePhase.idle;
|
||||||
|
List<FeedCard> _feed = const [];
|
||||||
|
String? _nextCursor;
|
||||||
|
bool _hasMore = false;
|
||||||
|
|
||||||
|
/// 首屏加载失败(error 态时非 null)。
|
||||||
|
ApiException? _lastError;
|
||||||
|
|
||||||
|
/// 刷新失败但旧列表被保留时的错误(页面 SnackBar 轻提示后消费)。
|
||||||
|
ApiException? _refreshError;
|
||||||
|
|
||||||
|
/// 加载更多失败(尾部重试条渲染依据)。
|
||||||
|
ApiException? _loadMoreError;
|
||||||
|
|
||||||
|
/// 最近一次点赞/收藏对账失败(T3-15/16 SnackBar 消费)。
|
||||||
|
ApiException? _toggleError;
|
||||||
|
|
||||||
|
/// 刷新代次:整体替换列表后,在途旧代次响应(尾页 / 互动对账)一律丢弃。
|
||||||
|
int _generation = 0;
|
||||||
|
|
||||||
|
/// 详情内存副本(详情页与 Feed 卡片互动状态同源)。
|
||||||
|
final Map<String, Post> _postCache = {};
|
||||||
|
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
FeedPhase get phase => _phase;
|
||||||
|
LoadMorePhase get loadMorePhase => _loadMorePhase;
|
||||||
|
|
||||||
|
/// 累积的多页 Feed 缓存(服务端 published_at DESC, id DESC 原样保留)。
|
||||||
|
List<FeedCard> get feed => _feed;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
|
||||||
|
/// ready 且列表为空 → 空态。
|
||||||
|
bool get isEmpty => _phase == FeedPhase.ready && _feed.isEmpty;
|
||||||
|
|
||||||
|
ApiException? get lastError => _lastError;
|
||||||
|
ApiException? get refreshError => _refreshError;
|
||||||
|
ApiException? get loadMoreError => _loadMoreError;
|
||||||
|
ApiException? get toggleError => _toggleError;
|
||||||
|
|
||||||
|
/// 详情内存副本(进入详情页先渲染缓存,再 [getPost] 拉新)。
|
||||||
|
Post? cachedPost(String postId) => _postCache[postId];
|
||||||
|
|
||||||
|
/// 首屏加载 / 下拉刷新:丢弃游标从头拉第一页,成功后**整体替换**累积列表。
|
||||||
|
/// 刷新失败保留旧列表(不清空不闪空态),错误经 [refreshError] 轻提示;
|
||||||
|
/// 首屏(空列表)失败收敛为 error 态供页面渲染 + retry。
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_generation += 1;
|
||||||
|
final generation = _generation;
|
||||||
|
_lastError = null;
|
||||||
|
_refreshError = null;
|
||||||
|
_loadMorePhase = LoadMorePhase.idle;
|
||||||
|
_loadMoreError = null;
|
||||||
|
if (_feed.isEmpty) _phase = FeedPhase.loading;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _repository.getFeed();
|
||||||
|
if (generation != _generation) return; // 期间又发生过刷新/登出。
|
||||||
|
_feed = page.items;
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_phase = FeedPhase.ready;
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (generation != _generation) return;
|
||||||
|
if (_feed.isEmpty) {
|
||||||
|
_lastError = error;
|
||||||
|
_phase = FeedPhase.error;
|
||||||
|
} else {
|
||||||
|
_refreshError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 滚动近底加载下一页:携带上一页 nextCursor(keyset 翻页不丢不重)。
|
||||||
|
/// 失败置 [LoadMorePhase.error] 渲染尾部重试条;期间发生过刷新的
|
||||||
|
/// 旧代次响应直接丢弃(避免「刷新后旧尾页追加」的重复/错位)。
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (_phase != FeedPhase.ready ||
|
||||||
|
!_hasMore ||
|
||||||
|
_loadMorePhase == LoadMorePhase.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final generation = _generation;
|
||||||
|
_loadMorePhase = LoadMorePhase.loading;
|
||||||
|
_loadMoreError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _repository.getFeed(cursor: _nextCursor);
|
||||||
|
if (generation != _generation) return; // 旧代次尾页,丢弃。
|
||||||
|
_feed = [..._feed, ...page.items];
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_loadMorePhase = LoadMorePhase.idle;
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (generation != _generation) return;
|
||||||
|
_loadMoreError = error;
|
||||||
|
_loadMorePhase = LoadMorePhase.error;
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取帖子详情并同步内存副本(Feed 卡片互动字段一并回写)。
|
||||||
|
Future<Post> getPost(String postId) async {
|
||||||
|
final post = await _repository.getPost(postId);
|
||||||
|
_postCache[postId] = post;
|
||||||
|
_syncCardFromPost(post);
|
||||||
|
_notify();
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞/取消点赞(乐观翻转,终态由 [ToggleSync] 对账收敛,不外抛)。
|
||||||
|
/// [source] 为触点来源(埋点归因),Feed 卡片缺省 feed、详情页传
|
||||||
|
/// post_detail。
|
||||||
|
void toggleLike(
|
||||||
|
String postId, {
|
||||||
|
InteractionSource source = InteractionSource.feed,
|
||||||
|
}) {
|
||||||
|
_likeSources[postId] = source;
|
||||||
|
_likeSync.toggle(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏/取消收藏(与点赞同构)。
|
||||||
|
void toggleBookmark(
|
||||||
|
String postId, {
|
||||||
|
InteractionSource source = InteractionSource.feed,
|
||||||
|
}) {
|
||||||
|
_bookmarkSources[postId] = source;
|
||||||
|
_bookmarkSync.toggle(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论创建/删除后的计数调整(详情页与 Feed 卡片同源写入,
|
||||||
|
/// 服务端 comment_count 由写侧同事务维护,本地只做展示对齐)。
|
||||||
|
void adjustCommentCount(String postId, int delta) {
|
||||||
|
final current =
|
||||||
|
_postCache[postId]?.commentCount ?? _cardOrNull(postId)?.commentCount;
|
||||||
|
if (current == null) return;
|
||||||
|
final next = current + delta;
|
||||||
|
_writeInteraction(postId, commentCount: next < 0 ? 0 : next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 消费一次性 toggle 错误(SnackBar 展示后清除)。
|
||||||
|
void clearToggleError() => _toggleError = null;
|
||||||
|
|
||||||
|
/// 登出清空:回 initial 态、清多页缓存与详情副本、丢弃全部在途链,
|
||||||
|
/// 避免上一账号数据跨会话泄漏;重登后主壳重建,Feed 页重新触发 [refresh]。
|
||||||
|
void reset() {
|
||||||
|
_generation += 1;
|
||||||
|
_phase = FeedPhase.initial;
|
||||||
|
_loadMorePhase = LoadMorePhase.idle;
|
||||||
|
_feed = const [];
|
||||||
|
_nextCursor = null;
|
||||||
|
_hasMore = false;
|
||||||
|
_lastError = null;
|
||||||
|
_refreshError = null;
|
||||||
|
_loadMoreError = null;
|
||||||
|
_toggleError = null;
|
||||||
|
_postCache.clear();
|
||||||
|
_likeSources.clear();
|
||||||
|
_bookmarkSources.clear();
|
||||||
|
_likeSync.reset();
|
||||||
|
_bookmarkSync.reset();
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
FeedCard? _cardOrNull(String postId) {
|
||||||
|
for (final card in _feed) {
|
||||||
|
if (card.id == postId) return card;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 互动字段统一写入口:详情副本与 Feed 卡片同步更新后 notify(同帧反馈)。
|
||||||
|
void _writeInteraction(
|
||||||
|
String postId, {
|
||||||
|
bool? likedByMe,
|
||||||
|
int? likeCount,
|
||||||
|
bool? bookmarkedByMe,
|
||||||
|
int? bookmarkCount,
|
||||||
|
int? commentCount,
|
||||||
|
}) {
|
||||||
|
final post = _postCache[postId];
|
||||||
|
if (post != null) {
|
||||||
|
_postCache[postId] = post.copyWithInteraction(
|
||||||
|
likedByMe: likedByMe,
|
||||||
|
likeCount: likeCount,
|
||||||
|
bookmarkedByMe: bookmarkedByMe,
|
||||||
|
bookmarkCount: bookmarkCount,
|
||||||
|
commentCount: commentCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final index = _feed.indexWhere((card) => card.id == postId);
|
||||||
|
if (index != -1) {
|
||||||
|
_feed = [..._feed]
|
||||||
|
..[index] = _feed[index].copyWithInteraction(
|
||||||
|
likedByMe: likedByMe,
|
||||||
|
likeCount: likeCount,
|
||||||
|
bookmarkedByMe: bookmarkedByMe,
|
||||||
|
bookmarkCount: bookmarkCount,
|
||||||
|
commentCount: commentCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (post != null || index != -1) _notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncCardFromPost(Post post) {
|
||||||
|
final index = _feed.indexWhere((card) => card.id == post.id);
|
||||||
|
if (index == -1) return;
|
||||||
|
_feed = [..._feed]
|
||||||
|
..[index] = _feed[index].copyWithInteraction(
|
||||||
|
likedByMe: post.likedByMe,
|
||||||
|
likeCount: post.likeCount,
|
||||||
|
commentCount: post.commentCount,
|
||||||
|
bookmarkedByMe: post.bookmarkedByMe,
|
||||||
|
bookmarkCount: post.bookmarkCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onToggleError(String postId, Object error) {
|
||||||
|
if (error is ApiException) {
|
||||||
|
_toggleError = error;
|
||||||
|
} else {
|
||||||
|
_toggleError = ApiNetworkException('$error');
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
|
||||||
|
/// 降级作者([AuthorSummary.isDegraded],资料暂不可得或已注销)的
|
||||||
|
/// 统一占位名(05 号规范:占位头像 + 默认名,客户端不做昵称回退拼装)。
|
||||||
|
const degradedAuthorName = '宠友';
|
||||||
|
|
||||||
|
/// 作者展示名:正常路径 nickname 恒非空(服务端已回退 username);
|
||||||
|
/// 降级形态统一 [degradedAuthorName]。
|
||||||
|
String authorDisplayName(AuthorSummary author) =>
|
||||||
|
author.nickname ?? degradedAuthorName;
|
||||||
|
|
||||||
|
/// Feed 卡片元信息的相对时间(正典「2 小时前」语言)。
|
||||||
|
/// [now] 注入口供测试锁定时钟。
|
||||||
|
String feedRelativeTime(DateTime time, {DateTime? now}) {
|
||||||
|
final reference = now ?? DateTime.now();
|
||||||
|
final difference = reference.difference(time.toLocal());
|
||||||
|
if (difference.inMinutes < 1) return '刚刚';
|
||||||
|
if (difference.inHours < 1) return '${difference.inMinutes} 分钟前';
|
||||||
|
if (difference.inDays < 1) return '${difference.inHours} 小时前';
|
||||||
|
if (difference.inDays < 7) return '${difference.inDays} 天前';
|
||||||
|
final local = time.toLocal();
|
||||||
|
if (local.year == reference.year) return '${local.month}月${local.day}日';
|
||||||
|
return '${local.year}年${local.month}月${local.day}日';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed 加载失败的用户话术(pets 域 petLoadErrorMessage 同构;
|
||||||
|
/// 服务端原始 message 不上屏)。
|
||||||
|
String feedLoadErrorMessage(ApiException? error) => switch (error) {
|
||||||
|
ApiNetworkException _ => '网络异常,请检查网络后重试',
|
||||||
|
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
|
||||||
|
_ => '动态加载失败,请稍后重试',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 发布失败的用户话术(T3-17,三条关键语义各自可辨;服务端原始
|
||||||
|
/// message 不上屏):
|
||||||
|
///
|
||||||
|
/// - 40905 同键异 payload:提交标识已被页面重置,再点一次即可;
|
||||||
|
/// - 42203 asset 未 ready:等图片传完再发;
|
||||||
|
/// - 网络失败:可重试(同键重放,服务端不会重复建帖)。
|
||||||
|
String postPublishErrorMessage(ApiException? error) => switch (error) {
|
||||||
|
IdempotencyMismatchException _ => '提交内容与上次重试不一致,已重置提交标识,请再点一次「发布」',
|
||||||
|
MediaNotReadyException _ => '有图片还没上传完成,请等图片就绪后再发布',
|
||||||
|
PostNotFoundException _ => '草稿已不存在(可能已在别处删除),请重新发布',
|
||||||
|
PostVersionConflictException _ => '草稿在别处被修改过,请重试发布',
|
||||||
|
ApiNetworkException _ => '网络异常,请检查网络后重试',
|
||||||
|
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
|
||||||
|
ApiBusinessException(:final code) when code == ApiCodes.paramError =>
|
||||||
|
'内容不符合发布要求,请修改后重试',
|
||||||
|
_ => '发布失败,请稍后重试',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 草稿保存失败的用户话术(发布页 SnackBar)。
|
||||||
|
String draftSaveErrorMessage(ApiException? error) => switch (error) {
|
||||||
|
ApiNetworkException _ => '网络异常,草稿未保存,请重试',
|
||||||
|
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
|
||||||
|
_ => '草稿保存失败,请重试',
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
|
||||||
|
/// community / media 域类型化业务异常(契约 v1.3.0 定型的 9 个新错误码,
|
||||||
|
/// 20 号收口报告 §1;40902 乐观锁与 pets 域共码,本域映射为独立类型)。
|
||||||
|
/// 全部继承 [ApiBusinessException],既有按基类捕获的通用错误处理不受影响。
|
||||||
|
|
||||||
|
/// 40301:对可见帖子/评论无相应操作权限(改删他人已发布帖、
|
||||||
|
/// 删他人可见评论——含帖主删他人评论,D3-7 首版不做)。
|
||||||
|
final class PostAccessDeniedException extends ApiBusinessException {
|
||||||
|
const PostAccessDeniedException({required super.message})
|
||||||
|
: super(code: ApiCodes.postAccessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40403:帖子不存在 / 已软删 / hidden/archived(含作者)/ 他人 draft
|
||||||
|
/// (防枚举,全部情况响应完全一致;互动路径上含作者本人草稿)。
|
||||||
|
final class PostNotFoundException extends ApiBusinessException {
|
||||||
|
const PostNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.postNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40404:评论不存在、已删或所属帖子不可见(防枚举合并)。
|
||||||
|
final class CommentNotFoundException extends ApiBusinessException {
|
||||||
|
const CommentNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.commentNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40405:media asset 不存在、非本人所有或已删(防枚举合并)。
|
||||||
|
final class MediaAssetNotFoundException extends ApiBusinessException {
|
||||||
|
const MediaAssetNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.mediaNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40406:目标用户不存在或已注销(合并不泄露成因)。
|
||||||
|
final class CommunityUserNotFoundException extends ApiBusinessException {
|
||||||
|
const CommunityUserNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.communityUserNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40902:帖子编辑乐观锁版本冲突(version 过期)。
|
||||||
|
/// 客户端处理:刷新详情取新 version 后重提。
|
||||||
|
final class PostVersionConflictException extends ApiBusinessException {
|
||||||
|
const PostVersionConflictException({required super.message})
|
||||||
|
: super(code: ApiCodes.versionConflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40905:同 Idempotency-Key 不同 payload(规范化 request_hash 不符)。
|
||||||
|
/// 客户端每次逻辑提交应换新键,重试间保持不变。
|
||||||
|
final class IdempotencyMismatchException extends ApiBusinessException {
|
||||||
|
const IdempotencyMismatchException({required super.message})
|
||||||
|
: super(code: ApiCodes.idempotencyKeyMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42203:引用了本人所有但非 ready(uploading/failed)状态的 asset。
|
||||||
|
final class MediaNotReadyException extends ApiBusinessException {
|
||||||
|
const MediaNotReadyException({required super.message})
|
||||||
|
: super(code: ApiCodes.mediaNotReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42204:自关注(仅 PUT;自取关走 DELETE 的 200 幂等 no-op)。
|
||||||
|
final class SelfFollowException extends ApiBusinessException {
|
||||||
|
const SelfFollowException({required super.message})
|
||||||
|
: super(code: ApiCodes.selfFollow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42205:上传状态不允许确认——对象未上传(保持 uploading 可重试)、
|
||||||
|
/// 大小/类型不符(置 failed 终态)、failed 态再确认(已 ready 幂等 200 除外)。
|
||||||
|
final class MediaUploadStateException extends ApiBusinessException {
|
||||||
|
const MediaUploadStateException({required super.message})
|
||||||
|
: super(code: ApiCodes.mediaUploadStateInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把通用业务异常按 community/media 域错误码升格为类型化异常;
|
||||||
|
/// 未覆盖的码(40000 参数错误、40401 宠物防枚举等)原样返回,沿用通用处理。
|
||||||
|
ApiBusinessException mapCommunityBusinessException(ApiBusinessException error) {
|
||||||
|
return switch (error.code) {
|
||||||
|
ApiCodes.postAccessDenied => PostAccessDeniedException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.postNotFound => PostNotFoundException(message: error.message),
|
||||||
|
ApiCodes.commentNotFound => CommentNotFoundException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.mediaNotFound => MediaAssetNotFoundException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.communityUserNotFound => CommunityUserNotFoundException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.versionConflict => PostVersionConflictException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.idempotencyKeyMismatch => IdempotencyMismatchException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.mediaNotReady => MediaNotReadyException(message: error.message),
|
||||||
|
ApiCodes.selfFollow => SelfFollowException(message: error.message),
|
||||||
|
ApiCodes.mediaUploadStateInvalid => MediaUploadStateException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
_ => error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
|
||||||
|
/// 互动域埋点强类型封装(06 号规划 §1.4 字典 v3 互动八事件;后端白名单
|
||||||
|
/// 已随 api dev@8089c06 就绪,22 号报告 §1)。沿 pet/feed 域惯例:枚举
|
||||||
|
/// 编译期锁死,业务代码禁止手拼事件名与属性;只记行为不记内容
|
||||||
|
/// (隐私红线:postId/commentId 等内容 ID 一律不进 props)。
|
||||||
|
///
|
||||||
|
/// 点赞/收藏/关注**不埋失败**(06 §1.4 取舍:幂等写入单点交互,失败率
|
||||||
|
/// 靠服务端错误率观测);`comment_create_started` 被字典锁死 unknown,
|
||||||
|
/// 不得上报(22 号 §1 末段)。
|
||||||
|
|
||||||
|
/// 互动触点来源(06 §1.4 source 枚举)。M3 接 feed / post_detail;
|
||||||
|
/// user_profile / follow_list 随后续页面启用。
|
||||||
|
enum InteractionSource {
|
||||||
|
feed('feed'),
|
||||||
|
postDetail('post_detail'),
|
||||||
|
userProfile('user_profile'),
|
||||||
|
followList('follow_list');
|
||||||
|
|
||||||
|
const InteractionSource(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论创建失败原因(06 §1.4 失败枚举基底)。网络归并口径同 pet/feed 域:
|
||||||
|
/// 断网/超时/5xx 均并入 network_error,server_error 保留兜底。
|
||||||
|
enum CommentCreateFailureReason {
|
||||||
|
validationError('validation_error'),
|
||||||
|
notFound('not_found'),
|
||||||
|
rateLimited('rate_limited'),
|
||||||
|
networkError('network_error'),
|
||||||
|
serverError('server_error');
|
||||||
|
|
||||||
|
const CommentCreateFailureReason(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 类型化异常 → 评论失败原因;会话失效返回 null(应用即将回登录页,
|
||||||
|
/// 不作为评论失败上报,feed 域同款口径)。
|
||||||
|
CommentCreateFailureReason? commentCreateFailureReasonOf(ApiException error) =>
|
||||||
|
switch (error) {
|
||||||
|
ApiNetworkException _ => CommentCreateFailureReason.networkError,
|
||||||
|
ApiRateLimitException _ => CommentCreateFailureReason.rateLimited,
|
||||||
|
SessionExpiredException _ => null,
|
||||||
|
ApiBusinessException(:final code) => switch (code) {
|
||||||
|
ApiCodes.paramError => CommentCreateFailureReason.validationError,
|
||||||
|
ApiCodes.postNotFound ||
|
||||||
|
ApiCodes.commentNotFound ||
|
||||||
|
ApiCodes.communityUserNotFound => CommentCreateFailureReason.notFound,
|
||||||
|
_ => CommentCreateFailureReason.serverError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 正文规模分桶(06 §1.3 隐私红线 1:不报精确字数)。
|
||||||
|
/// empty / short(≤50) / medium(51–500) / long(>500)。
|
||||||
|
String textLengthBucketOf(int length) {
|
||||||
|
if (length <= 0) return 'empty';
|
||||||
|
if (length <= 50) return 'short';
|
||||||
|
if (length <= 500) return 'medium';
|
||||||
|
return 'long';
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommunityInteractionAnalytics {
|
||||||
|
CommunityInteractionAnalytics(this._track);
|
||||||
|
|
||||||
|
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
|
||||||
|
final TrackEventFn _track;
|
||||||
|
|
||||||
|
/// 点赞成功响应后(乐观翻转本身不报;单飞合并链每个实际抵达服务端
|
||||||
|
/// 并成功的状态变更各报一条,与「成功响应后」的字典口径一致)。
|
||||||
|
void postLiked({required InteractionSource source}) {
|
||||||
|
_track('post_liked', {'source': source.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消点赞成功响应后。
|
||||||
|
void postUnliked({required InteractionSource source}) {
|
||||||
|
_track('post_unliked', {'source': source.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏成功响应后。
|
||||||
|
void postFavorited({required InteractionSource source}) {
|
||||||
|
_track('post_favorited', {'source': source.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消收藏成功响应后。
|
||||||
|
void postUnfavorited({required InteractionSource source}) {
|
||||||
|
_track('post_unfavorited', {'source': source.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论提交成功响应后。
|
||||||
|
///
|
||||||
|
/// [durationMs]:本次输入会话(首个字符输入)→ 成功响应的耗时
|
||||||
|
/// (评论不设 started 事件,时长随成功事件带出);
|
||||||
|
/// [textLength] 经 [textLengthBucketOf] 分桶后上报,精确字数不出端。
|
||||||
|
void commentCreateSucceeded({
|
||||||
|
required int durationMs,
|
||||||
|
required bool isReply,
|
||||||
|
required int textLength,
|
||||||
|
}) {
|
||||||
|
_track('comment_create_succeeded', {
|
||||||
|
'durationMs': durationMs,
|
||||||
|
'isReply': isReply,
|
||||||
|
'textLengthBucket': textLengthBucketOf(textLength),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论提交失败。
|
||||||
|
///
|
||||||
|
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位
|
||||||
|
/// 业务码推导(`code ~/ 100`,pet 域同款口径);[attemptSeq] 为本次
|
||||||
|
/// 输入会话内第几次提交尝试(从 1 起,成功或清空输入后重置)。
|
||||||
|
void commentCreateFailed({
|
||||||
|
required CommentCreateFailureReason reason,
|
||||||
|
required int attemptSeq,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('comment_create_failed', {
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'attemptSeq': attemptSeq,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关注成功响应后。
|
||||||
|
void userFollowed({required InteractionSource source}) {
|
||||||
|
_track('user_followed', {'source': source.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取关成功响应后。
|
||||||
|
void userUnfollowed({required InteractionSource source}) {
|
||||||
|
_track('user_unfollowed', {'source': source.value});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
/// community / media 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.3.0,
|
||||||
|
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
|
||||||
|
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'package:patbond_flutter/core/models/cursor_page.dart';
|
||||||
|
|
||||||
|
T _enumFromJson<T extends Enum>(List<T> values, String raw, String field) {
|
||||||
|
for (final value in values) {
|
||||||
|
if (value.name == raw) return value;
|
||||||
|
}
|
||||||
|
throw FormatException('未知的 $field 取值:$raw');
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _dateTimeOrNull(Object? value) =>
|
||||||
|
value == null ? null : DateTime.parse(value as String);
|
||||||
|
|
||||||
|
/// 帖子分类。ai_creation 为 M4 预留值,仅读侧出现(M3 提交即 400/40000)。
|
||||||
|
enum PostCategory {
|
||||||
|
general('general'),
|
||||||
|
help('help'),
|
||||||
|
aiCreation('ai_creation');
|
||||||
|
|
||||||
|
const PostCategory(this.wire);
|
||||||
|
|
||||||
|
/// 契约线上取值(aiCreation 的枚举名与线上 snake_case 不同,序列化走本值)。
|
||||||
|
final String wire;
|
||||||
|
|
||||||
|
static PostCategory fromJson(String value) {
|
||||||
|
for (final category in values) {
|
||||||
|
if (category.wire == value) return category;
|
||||||
|
}
|
||||||
|
throw FormatException('未知的 category 取值:$value');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子状态。hidden/archived(运营态)永不出现在响应(对作者与他人一律
|
||||||
|
/// 404/40403),枚举保持两值。
|
||||||
|
enum PostStatus {
|
||||||
|
draft,
|
||||||
|
published;
|
||||||
|
|
||||||
|
static PostStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子可见性。M3 恒 public(followers/private 语义后置,字段保留)。
|
||||||
|
enum PostVisibility {
|
||||||
|
public;
|
||||||
|
|
||||||
|
static PostVisibility fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'visibility');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// media asset 类型。M3 仅 image(视频后置,video/document 为向后新增预留)。
|
||||||
|
enum MediaKind {
|
||||||
|
image;
|
||||||
|
|
||||||
|
static MediaKind fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'kind');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传用途白名单(M3 定型仅 post_image,决定 objectKey 前缀)。
|
||||||
|
enum MediaPurpose {
|
||||||
|
postImage('post_image');
|
||||||
|
|
||||||
|
const MediaPurpose(this.wire);
|
||||||
|
|
||||||
|
final String wire;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// media asset 状态。deleted 态对外恒 404/40405,不出现在响应。
|
||||||
|
enum MediaAssetStatus {
|
||||||
|
uploading,
|
||||||
|
ready,
|
||||||
|
failed;
|
||||||
|
|
||||||
|
static MediaAssetStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 作者公开摘要(D3-9 方案 B)。正常路径 nickname 恒非空(空昵称由服务端
|
||||||
|
/// 回退为 username,客户端不做回退拼装);nickname 与 avatarUrl 同为 null
|
||||||
|
/// 即「降级/墓碑」形态(作者资料暂不可得或已注销),客户端只需一种占位逻辑。
|
||||||
|
class AuthorSummary {
|
||||||
|
const AuthorSummary({
|
||||||
|
required this.userId,
|
||||||
|
required this.nickname,
|
||||||
|
required this.avatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AuthorSummary.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AuthorSummary(
|
||||||
|
userId: json['userId'] as String,
|
||||||
|
nickname: json['nickname'] as String?,
|
||||||
|
// 时效性预签名 GET URL,每次响应现签,不得持久化、过期即重取。
|
||||||
|
avatarUrl: json['avatarUrl'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final String? nickname;
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
|
/// 降级/墓碑形态(id-only):页面渲染统一占位。
|
||||||
|
bool get isDegraded => nickname == null && avatarUrl == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子挂接的一张图(响应形态)。url 为时效性预签名 GET(TTL 默认 1 小时),
|
||||||
|
/// 每次响应现签,客户端不得持久化、过期即重取。
|
||||||
|
class PostMediaItem {
|
||||||
|
const PostMediaItem({
|
||||||
|
required this.assetId,
|
||||||
|
required this.position,
|
||||||
|
required this.isCover,
|
||||||
|
required this.url,
|
||||||
|
required this.widthPx,
|
||||||
|
required this.heightPx,
|
||||||
|
required this.caption,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PostMediaItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PostMediaItem(
|
||||||
|
assetId: json['assetId'] as String,
|
||||||
|
position: json['position'] as int,
|
||||||
|
isCover: json['isCover'] as bool,
|
||||||
|
url: json['url'] as String,
|
||||||
|
widthPx: json['widthPx'] as int?,
|
||||||
|
heightPx: json['heightPx'] as int?,
|
||||||
|
caption: json['caption'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String assetId;
|
||||||
|
final int position;
|
||||||
|
final bool isCover;
|
||||||
|
final String url;
|
||||||
|
final int? widthPx;
|
||||||
|
final int? heightPx;
|
||||||
|
final String? caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子挂接的一张图(请求形态)。position 全给或全不给(全给须恰为 0..n-1
|
||||||
|
/// 连续不重复,混合 400/40000);isCover 至多一个 true;caption trim 后 ≤300。
|
||||||
|
class PostMediaAttachRequest {
|
||||||
|
const PostMediaAttachRequest({
|
||||||
|
required this.assetId,
|
||||||
|
this.position,
|
||||||
|
this.isCover,
|
||||||
|
this.caption,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String assetId;
|
||||||
|
final int? position;
|
||||||
|
final bool? isCover;
|
||||||
|
final String? caption;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'assetId': assetId,
|
||||||
|
if (position != null) 'position': position,
|
||||||
|
if (isCover != null) 'isCover': isCover,
|
||||||
|
if (caption != null) 'caption': caption,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建帖子请求。content 必填(纯文字帖合法,media 空数组或缺席);
|
||||||
|
/// status=published 即创建即发布(服务端写 publishedAt)。
|
||||||
|
class CreatePostRequest {
|
||||||
|
const CreatePostRequest({
|
||||||
|
required this.content,
|
||||||
|
this.title,
|
||||||
|
this.category,
|
||||||
|
this.status,
|
||||||
|
this.petId,
|
||||||
|
this.media,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String content;
|
||||||
|
final String? title;
|
||||||
|
final PostCategory? category;
|
||||||
|
final PostStatus? status;
|
||||||
|
final String? petId;
|
||||||
|
final List<PostMediaAttachRequest>? media;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'content': content,
|
||||||
|
if (title != null) 'title': title,
|
||||||
|
if (category != null) 'category': category!.wire,
|
||||||
|
if (status != null) 'status': status!.name,
|
||||||
|
if (petId != null) 'petId': petId,
|
||||||
|
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑帖子 / 发布草稿请求(部分更新:缺席字段不变,不支持清空回 null;
|
||||||
|
/// version 乐观锁必带)。
|
||||||
|
///
|
||||||
|
/// - [publish]:`status: published` 状态迁移(draft→published 唯一开放迁移;
|
||||||
|
/// 对已发布帖重复提交为幂等 no-op,弱网重发不报错)。
|
||||||
|
/// - [media] 三态:null = 缺席不动;`[]` = 清空为纯文字帖;非空 = 整组替换。
|
||||||
|
class UpdatePostRequest {
|
||||||
|
const UpdatePostRequest({
|
||||||
|
required this.version,
|
||||||
|
this.title,
|
||||||
|
this.content,
|
||||||
|
this.category,
|
||||||
|
this.petId,
|
||||||
|
this.publish = false,
|
||||||
|
this.media,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int version;
|
||||||
|
final String? title;
|
||||||
|
final String? content;
|
||||||
|
final PostCategory? category;
|
||||||
|
final String? petId;
|
||||||
|
final bool publish;
|
||||||
|
final List<PostMediaAttachRequest>? media;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
if (title != null) 'title': title,
|
||||||
|
if (content != null) 'content': content,
|
||||||
|
if (category != null) 'category': category!.wire,
|
||||||
|
if (petId != null) 'petId': petId,
|
||||||
|
if (publish) 'status': PostStatus.published.name,
|
||||||
|
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子完整形态(详情 / 我的帖子列表 / 写响应共用)。
|
||||||
|
/// 「内容是否编辑过」以 version 为准(互动计数维护亦会推动 updatedAt)。
|
||||||
|
class Post {
|
||||||
|
const Post({
|
||||||
|
required this.id,
|
||||||
|
required this.author,
|
||||||
|
required this.petId,
|
||||||
|
required this.category,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
required this.status,
|
||||||
|
required this.visibility,
|
||||||
|
required this.media,
|
||||||
|
required this.likeCount,
|
||||||
|
required this.commentCount,
|
||||||
|
required this.bookmarkCount,
|
||||||
|
required this.likedByMe,
|
||||||
|
required this.bookmarkedByMe,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.publishedAt,
|
||||||
|
required this.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Post.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Post(
|
||||||
|
id: json['id'] as String,
|
||||||
|
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
|
||||||
|
petId: json['petId'] as String?,
|
||||||
|
category: PostCategory.fromJson(json['category'] as String),
|
||||||
|
title: json['title'] as String?,
|
||||||
|
content: json['content'] as String,
|
||||||
|
status: PostStatus.fromJson(json['status'] as String),
|
||||||
|
visibility: PostVisibility.fromJson(json['visibility'] as String),
|
||||||
|
media: (json['media'] as List)
|
||||||
|
.map((item) => PostMediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
likeCount: json['likeCount'] as int,
|
||||||
|
commentCount: json['commentCount'] as int,
|
||||||
|
bookmarkCount: json['bookmarkCount'] as int,
|
||||||
|
likedByMe: json['likedByMe'] as bool,
|
||||||
|
bookmarkedByMe: json['bookmarkedByMe'] as bool,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
// 仅 published 非空(发布时恰写一次)。
|
||||||
|
publishedAt: _dateTimeOrNull(json['publishedAt']),
|
||||||
|
version: json['version'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final AuthorSummary author;
|
||||||
|
final String? petId;
|
||||||
|
final PostCategory category;
|
||||||
|
final String? title;
|
||||||
|
final String content;
|
||||||
|
final PostStatus status;
|
||||||
|
final PostVisibility visibility;
|
||||||
|
final List<PostMediaItem> media;
|
||||||
|
final int likeCount;
|
||||||
|
final int commentCount;
|
||||||
|
final int bookmarkCount;
|
||||||
|
final bool likedByMe;
|
||||||
|
final bool bookmarkedByMe;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final DateTime? publishedAt;
|
||||||
|
final int version;
|
||||||
|
|
||||||
|
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
|
||||||
|
Post copyWithInteraction({
|
||||||
|
int? likeCount,
|
||||||
|
int? commentCount,
|
||||||
|
int? bookmarkCount,
|
||||||
|
bool? likedByMe,
|
||||||
|
bool? bookmarkedByMe,
|
||||||
|
}) {
|
||||||
|
return Post(
|
||||||
|
id: id,
|
||||||
|
author: author,
|
||||||
|
petId: petId,
|
||||||
|
category: category,
|
||||||
|
title: title,
|
||||||
|
content: content,
|
||||||
|
status: status,
|
||||||
|
visibility: visibility,
|
||||||
|
media: media,
|
||||||
|
likeCount: likeCount ?? this.likeCount,
|
||||||
|
commentCount: commentCount ?? this.commentCount,
|
||||||
|
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
|
||||||
|
likedByMe: likedByMe ?? this.likedByMe,
|
||||||
|
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
publishedAt: publishedAt,
|
||||||
|
version: version,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed / 收藏列表卡片形态(较 Post 裁剪:只带 coverImage + mediaCount,
|
||||||
|
/// 全文恒走帖子详情端点)。publishedAt 恒非空(谓词只放行 published)。
|
||||||
|
class FeedCard {
|
||||||
|
const FeedCard({
|
||||||
|
required this.id,
|
||||||
|
required this.author,
|
||||||
|
required this.category,
|
||||||
|
required this.title,
|
||||||
|
required this.contentPreview,
|
||||||
|
required this.coverImage,
|
||||||
|
required this.mediaCount,
|
||||||
|
required this.likeCount,
|
||||||
|
required this.commentCount,
|
||||||
|
required this.bookmarkCount,
|
||||||
|
required this.likedByMe,
|
||||||
|
required this.bookmarkedByMe,
|
||||||
|
required this.publishedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FeedCard.fromJson(Map<String, dynamic> json) {
|
||||||
|
final cover = json['coverImage'];
|
||||||
|
return FeedCard(
|
||||||
|
id: json['id'] as String,
|
||||||
|
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
|
||||||
|
category: PostCategory.fromJson(json['category'] as String),
|
||||||
|
title: json['title'] as String?,
|
||||||
|
contentPreview: json['contentPreview'] as String,
|
||||||
|
// 封面 = 库中唯一 is_cover 行;纯文字帖为 null。
|
||||||
|
coverImage: cover == null
|
||||||
|
? null
|
||||||
|
: PostMediaItem.fromJson(cover as Map<String, dynamic>),
|
||||||
|
mediaCount: json['mediaCount'] as int,
|
||||||
|
likeCount: json['likeCount'] as int,
|
||||||
|
commentCount: json['commentCount'] as int,
|
||||||
|
bookmarkCount: json['bookmarkCount'] as int,
|
||||||
|
likedByMe: json['likedByMe'] as bool,
|
||||||
|
bookmarkedByMe: json['bookmarkedByMe'] as bool,
|
||||||
|
publishedAt: DateTime.parse(json['publishedAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final AuthorSummary author;
|
||||||
|
final PostCategory category;
|
||||||
|
final String? title;
|
||||||
|
final String contentPreview;
|
||||||
|
final PostMediaItem? coverImage;
|
||||||
|
final int mediaCount;
|
||||||
|
final int likeCount;
|
||||||
|
final int commentCount;
|
||||||
|
final int bookmarkCount;
|
||||||
|
final bool likedByMe;
|
||||||
|
final bool bookmarkedByMe;
|
||||||
|
final DateTime publishedAt;
|
||||||
|
|
||||||
|
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
|
||||||
|
FeedCard copyWithInteraction({
|
||||||
|
int? likeCount,
|
||||||
|
int? commentCount,
|
||||||
|
int? bookmarkCount,
|
||||||
|
bool? likedByMe,
|
||||||
|
bool? bookmarkedByMe,
|
||||||
|
}) {
|
||||||
|
return FeedCard(
|
||||||
|
id: id,
|
||||||
|
author: author,
|
||||||
|
category: category,
|
||||||
|
title: title,
|
||||||
|
contentPreview: contentPreview,
|
||||||
|
coverImage: coverImage,
|
||||||
|
mediaCount: mediaCount,
|
||||||
|
likeCount: likeCount ?? this.likeCount,
|
||||||
|
commentCount: commentCount ?? this.commentCount,
|
||||||
|
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
|
||||||
|
likedByMe: likedByMe ?? this.likedByMe,
|
||||||
|
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
|
||||||
|
publishedAt: publishedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建评论请求。content trim 后 1~2000;replyToUserId 可选 @ 回复目标
|
||||||
|
/// (单层平铺,无楼中楼)。
|
||||||
|
class CreateCommentRequest {
|
||||||
|
const CreateCommentRequest({required this.content, this.replyToUserId});
|
||||||
|
|
||||||
|
final String content;
|
||||||
|
final String? replyToUserId;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'content': content,
|
||||||
|
if (replyToUserId != null) 'replyToUserId': replyToUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论(M3 无评论编辑,不带 updatedAt)。
|
||||||
|
class PostComment {
|
||||||
|
const PostComment({
|
||||||
|
required this.id,
|
||||||
|
required this.postId,
|
||||||
|
required this.author,
|
||||||
|
required this.replyToUser,
|
||||||
|
required this.content,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PostComment.fromJson(Map<String, dynamic> json) {
|
||||||
|
final replyTo = json['replyToUser'];
|
||||||
|
return PostComment(
|
||||||
|
id: json['id'] as String,
|
||||||
|
postId: json['postId'] as String,
|
||||||
|
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
|
||||||
|
// @ 回复目标公开摘要(含降级 id-only 形态);非回复为 null。
|
||||||
|
replyToUser: replyTo == null
|
||||||
|
? null
|
||||||
|
: AuthorSummary.fromJson(replyTo as Map<String, dynamic>),
|
||||||
|
content: json['content'] as String,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String postId;
|
||||||
|
final AuthorSummary author;
|
||||||
|
final AuthorSummary? replyToUser;
|
||||||
|
final String content;
|
||||||
|
final DateTime createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建上传请求(两步上传第一步)。mimeType 白名单
|
||||||
|
/// image/jpeg|png|webp,byteSize ≤ 10485760(10 MiB,服务端配置项);
|
||||||
|
/// sha256 可选(64 位小写 hex,M3 照收照存不核验)。
|
||||||
|
class CreateMediaUploadRequest {
|
||||||
|
const CreateMediaUploadRequest({
|
||||||
|
required this.kind,
|
||||||
|
required this.purpose,
|
||||||
|
required this.mimeType,
|
||||||
|
required this.byteSize,
|
||||||
|
this.sha256,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MediaKind kind;
|
||||||
|
final MediaPurpose purpose;
|
||||||
|
final String mimeType;
|
||||||
|
final int byteSize;
|
||||||
|
final String? sha256;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'kind': kind.name,
|
||||||
|
'purpose': purpose.wire,
|
||||||
|
'mimeType': mimeType,
|
||||||
|
'byteSize': byteSize,
|
||||||
|
if (sha256 != null) 'sha256': sha256,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预签名直传凭据。凭据(uploadUrl 含签名)TTL 默认 10 分钟,过期后
|
||||||
|
/// 重新创建上传;直传必须原样携带 requiredHeaders(Content-Type 已签进
|
||||||
|
/// 签名,改动即被存储侧拒绝)。凭据会过期,不得持久化。
|
||||||
|
class MediaUploadCredentials {
|
||||||
|
const MediaUploadCredentials({
|
||||||
|
required this.assetId,
|
||||||
|
required this.uploadUrl,
|
||||||
|
required this.method,
|
||||||
|
required this.requiredHeaders,
|
||||||
|
required this.expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MediaUploadCredentials.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MediaUploadCredentials(
|
||||||
|
assetId: json['assetId'] as String,
|
||||||
|
uploadUrl: json['uploadUrl'] as String,
|
||||||
|
method: json['method'] as String,
|
||||||
|
requiredHeaders: (json['requiredHeaders'] as Map<String, dynamic>).map(
|
||||||
|
(key, value) => MapEntry(key, value as String),
|
||||||
|
),
|
||||||
|
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String assetId;
|
||||||
|
final String uploadUrl;
|
||||||
|
|
||||||
|
/// 契约定型恒为 PUT(enum 单值;直传时按本值发起请求)。
|
||||||
|
final String method;
|
||||||
|
final Map<String, String> requiredHeaders;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// media asset(confirm 后的可引用形态)。url 仅 ready 态非空——时效性
|
||||||
|
/// 预签名 GET(TTL 默认 1 小时),每次响应现签,不得持久化、过期即重取。
|
||||||
|
class MediaAsset {
|
||||||
|
const MediaAsset({
|
||||||
|
required this.id,
|
||||||
|
required this.kind,
|
||||||
|
required this.purpose,
|
||||||
|
required this.mimeType,
|
||||||
|
required this.byteSize,
|
||||||
|
required this.widthPx,
|
||||||
|
required this.heightPx,
|
||||||
|
required this.status,
|
||||||
|
required this.url,
|
||||||
|
required this.readyAt,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MediaAsset.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MediaAsset(
|
||||||
|
id: json['id'] as String,
|
||||||
|
kind: MediaKind.fromJson(json['kind'] as String),
|
||||||
|
purpose: json['purpose'] as String,
|
||||||
|
mimeType: json['mimeType'] as String,
|
||||||
|
byteSize: json['byteSize'] as int?,
|
||||||
|
widthPx: json['widthPx'] as int?,
|
||||||
|
heightPx: json['heightPx'] as int?,
|
||||||
|
status: MediaAssetStatus.fromJson(json['status'] as String),
|
||||||
|
url: json['url'] as String?,
|
||||||
|
readyAt: _dateTimeOrNull(json['readyAt']),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final MediaKind kind;
|
||||||
|
final String purpose;
|
||||||
|
final String mimeType;
|
||||||
|
final int? byteSize;
|
||||||
|
final int? widthPx;
|
||||||
|
final int? heightPx;
|
||||||
|
final MediaAssetStatus status;
|
||||||
|
final String? url;
|
||||||
|
final DateTime? readyAt;
|
||||||
|
final DateTime createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞权威终态(乐观更新以此对账回滚,回滚基准取响应值)。
|
||||||
|
class LikeState {
|
||||||
|
const LikeState({required this.liked, required this.likeCount});
|
||||||
|
|
||||||
|
factory LikeState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LikeState(
|
||||||
|
liked: json['liked'] as bool,
|
||||||
|
likeCount: json['likeCount'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool liked;
|
||||||
|
final int likeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏权威终态(与点赞同构)。
|
||||||
|
class BookmarkState {
|
||||||
|
const BookmarkState({required this.bookmarked, required this.bookmarkCount});
|
||||||
|
|
||||||
|
factory BookmarkState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BookmarkState(
|
||||||
|
bookmarked: json['bookmarked'] as bool,
|
||||||
|
bookmarkCount: json['bookmarkCount'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool bookmarked;
|
||||||
|
final int bookmarkCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关注权威终态;followerCount 为目标用户的粉丝数(实时 COUNT)。
|
||||||
|
class FollowState {
|
||||||
|
const FollowState({required this.following, required this.followerCount});
|
||||||
|
|
||||||
|
factory FollowState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FollowState(
|
||||||
|
following: json['following'] as bool,
|
||||||
|
followerCount: json['followerCount'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool following;
|
||||||
|
final int followerCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关注计数(关注数 / 粉丝数 / 我是否已关注;查自己 followedByMe 恒 false)。
|
||||||
|
class FollowStats {
|
||||||
|
const FollowStats({
|
||||||
|
required this.followerCount,
|
||||||
|
required this.followingCount,
|
||||||
|
required this.followedByMe,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FollowStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FollowStats(
|
||||||
|
followerCount: json['followerCount'] as int,
|
||||||
|
followingCount: json['followingCount'] as int,
|
||||||
|
followedByMe: json['followedByMe'] as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int followerCount;
|
||||||
|
final int followingCount;
|
||||||
|
final bool followedByMe;
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// community / media 域仓库接口(契约 v1.3.0 的 13 路径 / 19 操作全覆盖;
|
||||||
|
/// 页面依赖此抽象,widget / controller 测试注入假实现)。
|
||||||
|
///
|
||||||
|
/// media 两步上传本单只到协议层(创建上传 / 确认上传);预签名 PUT
|
||||||
|
/// 直传对象存储不走业务信封与 Bearer 鉴权,属 T3-13 独立客户端。
|
||||||
|
abstract class CommunityRepository {
|
||||||
|
// ---- media 两步上传(协议层)----
|
||||||
|
Future<MediaUploadCredentials> createMediaUpload(
|
||||||
|
CreateMediaUploadRequest request,
|
||||||
|
);
|
||||||
|
Future<MediaAsset> completeMediaUpload(String assetId);
|
||||||
|
|
||||||
|
// ---- 帖子 CRUD / 发布 ----
|
||||||
|
/// [idempotencyKey]:调用方持键(T3-17 发布页「同键重放」——网络失败重试
|
||||||
|
/// 沿用同键命中服务端首帖,不重复建帖;缺省则本层每次调用换新键)。
|
||||||
|
Future<Post> createPost(CreatePostRequest request, {String? idempotencyKey});
|
||||||
|
Future<Post> getPost(String postId);
|
||||||
|
Future<Post> updatePost(String postId, UpdatePostRequest request);
|
||||||
|
Future<void> deletePost(String postId);
|
||||||
|
Future<CursorPage<Post>> listMyPosts({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
PostStatus? status,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Feed(cursor 分页)----
|
||||||
|
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor});
|
||||||
|
|
||||||
|
// ---- 评论 ----
|
||||||
|
Future<CursorPage<PostComment>> listComments(
|
||||||
|
String postId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
});
|
||||||
|
Future<PostComment> createComment(
|
||||||
|
String postId,
|
||||||
|
CreateCommentRequest request,
|
||||||
|
);
|
||||||
|
Future<void> deleteComment(String commentId);
|
||||||
|
|
||||||
|
// ---- 点赞 / 收藏(PUT/DELETE 语义幂等,响应权威终态)----
|
||||||
|
Future<LikeState> likePost(String postId);
|
||||||
|
Future<LikeState> unlikePost(String postId);
|
||||||
|
Future<BookmarkState> bookmarkPost(String postId);
|
||||||
|
Future<BookmarkState> unbookmarkPost(String postId);
|
||||||
|
Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor});
|
||||||
|
|
||||||
|
// ---- 关注 ----
|
||||||
|
Future<FollowState> followUser(String userId);
|
||||||
|
Future<FollowState> unfollowUser(String userId);
|
||||||
|
Future<FollowStats> getFollowStats(String userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
|
||||||
|
/// 拦截 + 401/40101 单飞刷新重放);community 域业务错误码升格为类型化异常。
|
||||||
|
///
|
||||||
|
/// 幂等:createPost / createComment 两个 POST 按契约**必带**
|
||||||
|
/// `Idempotency-Key`(1~128 字符;每次逻辑提交换新键;token 刷新后的
|
||||||
|
/// 自动重放沿用同一个键——键在本层每次调用生成一次,重放走同一 headers)。
|
||||||
|
/// 点赞/收藏/关注为 PUT/DELETE 语义幂等,无需幂等键。
|
||||||
|
///
|
||||||
|
/// 端口线路:community 域端点走 community 服务客户端;media 两步上传
|
||||||
|
/// 端点由 **user 服务**提供(13 号报告 §2,MediaController 在
|
||||||
|
/// patbond-user),经 [mediaApi] 直连——T3-13 真链路实测修正,
|
||||||
|
/// 未提供时回落主客户端(既有测试桩场景)。
|
||||||
|
class ApiCommunityRepository implements CommunityRepository {
|
||||||
|
ApiCommunityRepository({
|
||||||
|
required ApiClient api,
|
||||||
|
ApiClient? mediaApi,
|
||||||
|
this._uuid = const Uuid(),
|
||||||
|
}) : _api = api,
|
||||||
|
_mediaApi = mediaApi ?? api;
|
||||||
|
|
||||||
|
final ApiClient _api;
|
||||||
|
final ApiClient _mediaApi;
|
||||||
|
final Uuid _uuid;
|
||||||
|
|
||||||
|
Future<Object?> _request(
|
||||||
|
String path, {
|
||||||
|
String method = 'GET',
|
||||||
|
Object? body,
|
||||||
|
Map<String, Object?>? query,
|
||||||
|
bool idempotent = false,
|
||||||
|
String? idempotencyKey,
|
||||||
|
bool media = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await (media ? _mediaApi : _api).request(
|
||||||
|
path,
|
||||||
|
method: method,
|
||||||
|
body: body,
|
||||||
|
query: query,
|
||||||
|
headers: idempotent
|
||||||
|
? {'Idempotency-Key': idempotencyKey ?? _uuid.v4()}
|
||||||
|
: null,
|
||||||
|
requiresAuth: true,
|
||||||
|
);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
throw mapCommunityBusinessException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _asMap(Object? data) => data! as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// ---- media ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaUploadCredentials> createMediaUpload(
|
||||||
|
CreateMediaUploadRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/media/uploads',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
media: true,
|
||||||
|
);
|
||||||
|
return MediaUploadCredentials.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaAsset> completeMediaUpload(String assetId) async {
|
||||||
|
// 幂等由服务端保证:已 ready 重复 confirm 返回 200 同一 asset。
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/media/uploads/$assetId/complete',
|
||||||
|
method: 'POST',
|
||||||
|
media: true,
|
||||||
|
);
|
||||||
|
return MediaAsset.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- posts ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> createPost(
|
||||||
|
CreatePostRequest request, {
|
||||||
|
String? idempotencyKey,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
idempotencyKey: idempotencyKey,
|
||||||
|
);
|
||||||
|
return Post.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> getPost(String postId) async {
|
||||||
|
final data = await _request('/api/v1/posts/$postId');
|
||||||
|
return Post.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> updatePost(String postId, UpdatePostRequest request) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return Post.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deletePost(String postId) async {
|
||||||
|
await _request('/api/v1/posts/$postId', method: 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<Post>> listMyPosts({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
PostStatus? status,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/me/posts',
|
||||||
|
query: {
|
||||||
|
'limit': ?limit,
|
||||||
|
'cursor': ?cursor,
|
||||||
|
if (status != null) 'status': status.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), Post.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- feed ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/feed',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), FeedCard.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- comments ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<PostComment>> listComments(
|
||||||
|
String postId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/comments',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), PostComment.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PostComment> createComment(
|
||||||
|
String postId,
|
||||||
|
CreateCommentRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/comments',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return PostComment.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteComment(String commentId) async {
|
||||||
|
// 顶层短路径先例:commentId 全局唯一。
|
||||||
|
await _request('/api/v1/comments/$commentId', method: 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- interactions ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> likePost(String postId) async {
|
||||||
|
final data = await _request('/api/v1/posts/$postId/like', method: 'PUT');
|
||||||
|
return LikeState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> unlikePost(String postId) async {
|
||||||
|
final data = await _request('/api/v1/posts/$postId/like', method: 'DELETE');
|
||||||
|
return LikeState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> bookmarkPost(String postId) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/bookmark',
|
||||||
|
method: 'PUT',
|
||||||
|
);
|
||||||
|
return BookmarkState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> unbookmarkPost(String postId) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/bookmark',
|
||||||
|
method: 'DELETE',
|
||||||
|
);
|
||||||
|
return BookmarkState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> listMyBookmarks({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/me/bookmarks',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), FeedCard.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- follows ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> followUser(String userId) async {
|
||||||
|
final data = await _request('/api/v1/users/$userId/follow', method: 'PUT');
|
||||||
|
return FollowState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> unfollowUser(String userId) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/users/$userId/follow',
|
||||||
|
method: 'DELETE',
|
||||||
|
);
|
||||||
|
return FollowState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowStats> getFollowStats(String userId) async {
|
||||||
|
final data = await _request('/api/v1/users/$userId/follow-stats');
|
||||||
|
return FollowStats.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
|
||||||
|
/// feed 域埋点强类型封装(06 号规划 §1.4 字典 v3;后端白名单已随
|
||||||
|
/// api dev@8089c06 就绪)。沿 pet_analytics 惯例:枚举编译期锁死,
|
||||||
|
/// 业务代码禁止手拼事件名与属性;只记行为不记内容(隐私红线:postId
|
||||||
|
/// 等内容 ID 一律不进 props,曝光去重键只存活于客户端内存)。
|
||||||
|
|
||||||
|
/// feed_viewed / feed_load_failed 的 feedTab 枚举(06 §1.4)。
|
||||||
|
/// M3 T3-14 仅接 home;topic / user_posts / favorites 随后续页面启用。
|
||||||
|
enum FeedTab {
|
||||||
|
home('home'),
|
||||||
|
topic('topic'),
|
||||||
|
userPosts('user_posts'),
|
||||||
|
favorites('favorites');
|
||||||
|
|
||||||
|
const FeedTab(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// feed_load_failed 的 loadType 枚举。
|
||||||
|
enum FeedLoadType {
|
||||||
|
refresh('refresh'),
|
||||||
|
loadMore('load_more');
|
||||||
|
|
||||||
|
const FeedLoadType(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// feed 加载失败原因(06 v3 失败枚举基底)。网络归并口径同 pet 域:
|
||||||
|
/// 断网/超时/5xx 均并入 network_error,server_error 保留兜底。
|
||||||
|
enum FeedLoadFailureReason {
|
||||||
|
rateLimited('rate_limited'),
|
||||||
|
networkError('network_error'),
|
||||||
|
serverError('server_error');
|
||||||
|
|
||||||
|
const FeedLoadFailureReason(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 类型化异常 → 失败原因;会话失效返回 null(应用即将回登录页,
|
||||||
|
/// 不作为 Feed 加载失败上报)。
|
||||||
|
FeedLoadFailureReason? feedLoadFailureReasonOf(ApiException error) =>
|
||||||
|
switch (error) {
|
||||||
|
ApiNetworkException _ => FeedLoadFailureReason.networkError,
|
||||||
|
ApiRateLimitException _ => FeedLoadFailureReason.rateLimited,
|
||||||
|
SessionExpiredException _ => null,
|
||||||
|
_ => FeedLoadFailureReason.serverError,
|
||||||
|
};
|
||||||
|
|
||||||
|
class FeedAnalytics {
|
||||||
|
FeedAnalytics(this._track);
|
||||||
|
|
||||||
|
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
|
||||||
|
final TrackEventFn _track;
|
||||||
|
|
||||||
|
/// 一个 Feed 浏览段的聚合曝光(06 §1.2 裁定):离开 Feed(路由跳走 /
|
||||||
|
/// 退后台)时发一条,携带段内曝光卡片数(≥50% 可见 ≥500ms、按帖去重
|
||||||
|
/// 后的**计数**)、翻页数、刷新数与前台停留时长。
|
||||||
|
void feedViewed({
|
||||||
|
required FeedTab feedTab,
|
||||||
|
required int durationMs,
|
||||||
|
required int impressionCount,
|
||||||
|
required int loadMoreCount,
|
||||||
|
required int refreshCount,
|
||||||
|
}) {
|
||||||
|
_track('feed_viewed', {
|
||||||
|
'feedTab': feedTab.value,
|
||||||
|
'durationMs': durationMs,
|
||||||
|
'impressionCount': impressionCount,
|
||||||
|
'loadMoreCount': loadMoreCount,
|
||||||
|
'refreshCount': refreshCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新或翻页请求失败(M3 验收「分页不丢失不重复」的客户端观测点)。
|
||||||
|
///
|
||||||
|
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位
|
||||||
|
/// 业务码推导(`code ~/ 100`,pet 域同款口径)。
|
||||||
|
void feedLoadFailed({
|
||||||
|
required FeedTab feedTab,
|
||||||
|
required FeedLoadType loadType,
|
||||||
|
required FeedLoadFailureReason reason,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('feed_load_failed', {
|
||||||
|
'feedTab': feedTab.value,
|
||||||
|
'loadType': loadType.value,
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [feedLoadFailed] 的异常直通口:会话失效不上报。
|
||||||
|
void feedLoadFailedFrom(
|
||||||
|
ApiException error, {
|
||||||
|
required FeedTab feedTab,
|
||||||
|
required FeedLoadType loadType,
|
||||||
|
}) {
|
||||||
|
final reason = feedLoadFailureReasonOf(error);
|
||||||
|
if (reason == null) return;
|
||||||
|
feedLoadFailed(
|
||||||
|
feedTab: feedTab,
|
||||||
|
loadType: loadType,
|
||||||
|
reason: reason,
|
||||||
|
errorCode: error is ApiBusinessException ? error.code : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||||
|
|
||||||
|
/// 一个「Feed 浏览段」的聚合器(06 号规划 §1.2 裁定的客户端实现)。
|
||||||
|
///
|
||||||
|
/// 段生命周期由页面驱动:进入 Feed(Tab 激活且分段在 Feed)开段;
|
||||||
|
/// 离开(切 Tab / 切分段 / 退后台 / 页面销毁)时 [settle] 结算并经
|
||||||
|
/// [FeedAnalytics.feedViewed] 发**一条**聚合事件。
|
||||||
|
///
|
||||||
|
/// - 曝光判定:卡片可见面积 ≥ [visibleThreshold] 且持续 ≥ [dwell],
|
||||||
|
/// 段内按 postId 去重;postId 只作内存去重键、绝不上报(隐私红线 2),
|
||||||
|
/// 段结束即弃。
|
||||||
|
/// - durationMs 为前台时长(退后台即结算,段天然前台连续),上限
|
||||||
|
/// 截断 30 分钟防挂机污染(06 §1.4 实现注意)。
|
||||||
|
class FeedViewSegment {
|
||||||
|
FeedViewSegment({DateTime Function()? now})
|
||||||
|
: _now = now ?? DateTime.now,
|
||||||
|
_settled = false {
|
||||||
|
_startedAt = _now();
|
||||||
|
}
|
||||||
|
|
||||||
|
static const visibleThreshold = 0.5;
|
||||||
|
static const dwell = Duration(milliseconds: 500);
|
||||||
|
static const maxDuration = Duration(minutes: 30);
|
||||||
|
|
||||||
|
final DateTime Function() _now;
|
||||||
|
late final DateTime _startedAt;
|
||||||
|
|
||||||
|
final Set<String> _impressed = <String>{};
|
||||||
|
final Map<String, Timer> _dwellTimers = <String, Timer>{};
|
||||||
|
int _loadMoreCount = 0;
|
||||||
|
int _refreshCount = 0;
|
||||||
|
bool _settled;
|
||||||
|
|
||||||
|
/// 段内曝光卡片数(已去重;仅测试与结算读取)。
|
||||||
|
int get impressionCount => _impressed.length;
|
||||||
|
|
||||||
|
/// 可见性回报:≥50% 起 500ms 驻留计时,跌破或滚出视口即取消;
|
||||||
|
/// 驻留满即记曝光(去重后不再计时)。
|
||||||
|
void updateVisibility(String postId, double visibleFraction) {
|
||||||
|
if (_settled) return;
|
||||||
|
if (visibleFraction >= visibleThreshold) {
|
||||||
|
if (_impressed.contains(postId) || _dwellTimers.containsKey(postId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_dwellTimers[postId] = Timer(dwell, () {
|
||||||
|
_dwellTimers.remove(postId);
|
||||||
|
_impressed.add(postId);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_dwellTimers.remove(postId)?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户触发的下拉刷新 / 失败重试(首屏自动预取不计)。
|
||||||
|
void recordRefresh() {
|
||||||
|
if (!_settled) _refreshCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 触底翻页请求(含尾部失败重试)。
|
||||||
|
void recordLoadMore() {
|
||||||
|
if (!_settled) _loadMoreCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结算:取消在途驻留计时并冻结计数;幂等(重复调用返回 null,
|
||||||
|
/// 保证一段恰好一条 feed_viewed)。
|
||||||
|
FeedViewSummary? settle() {
|
||||||
|
if (_settled) return null;
|
||||||
|
_settled = true;
|
||||||
|
for (final timer in _dwellTimers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_dwellTimers.clear();
|
||||||
|
final elapsed = _now().difference(_startedAt);
|
||||||
|
return FeedViewSummary(
|
||||||
|
durationMs: min(elapsed.inMilliseconds, maxDuration.inMilliseconds),
|
||||||
|
impressionCount: _impressed.length,
|
||||||
|
loadMoreCount: _loadMoreCount,
|
||||||
|
refreshCount: _refreshCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [FeedViewSegment.settle] 的结算结果(feed_viewed 专有属性)。
|
||||||
|
class FeedViewSummary {
|
||||||
|
const FeedViewSummary({
|
||||||
|
required this.durationMs,
|
||||||
|
required this.impressionCount,
|
||||||
|
required this.loadMoreCount,
|
||||||
|
required this.refreshCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int durationMs;
|
||||||
|
final int impressionCount;
|
||||||
|
final int loadMoreCount;
|
||||||
|
final int refreshCount;
|
||||||
|
|
||||||
|
/// 结算即上报的便捷口。
|
||||||
|
void report(FeedAnalytics analytics, {FeedTab feedTab = FeedTab.home}) {
|
||||||
|
analytics.feedViewed(
|
||||||
|
feedTab: feedTab,
|
||||||
|
durationMs: durationMs,
|
||||||
|
impressionCount: impressionCount,
|
||||||
|
loadMoreCount: loadMoreCount,
|
||||||
|
refreshCount: refreshCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_picking.dart';
|
||||||
|
|
||||||
|
/// 压缩产物(直传本体)。mimeType 与直传 PUT 的 Content-Type、
|
||||||
|
/// createUpload 登记值三处必须一致(Content-Type 已签进预签名签名)。
|
||||||
|
class CompressedMediaImage {
|
||||||
|
const CompressedMediaImage({required this.bytes, required this.mimeType});
|
||||||
|
|
||||||
|
final Uint8List bytes;
|
||||||
|
final String mimeType;
|
||||||
|
|
||||||
|
int get byteSize => bytes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 压缩抽象:quality 由 MediaUploader 的降质阶梯驱动(80 → 60),
|
||||||
|
/// 单测注入假实现控制产物大小。
|
||||||
|
abstract class MediaImageCompressor {
|
||||||
|
Future<CompressedMediaImage> compress(
|
||||||
|
PickedMediaImage source, {
|
||||||
|
required int quality,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于 flutter_image_compress 的原生压缩实现(03 号评估 §4.1 选型)。
|
||||||
|
///
|
||||||
|
/// 策略(03 号 §4.1 + 13 号报告偏差 #6):长边 ≤2048 重采样、统一转码
|
||||||
|
/// JPEG(服务端 mime 白名单不收 HEIC,客户端统一出 jpeg);
|
||||||
|
/// autoCorrectionAngle 矫正方向后不保留 EXIF——顺带剥离 GPS 定位隐私,
|
||||||
|
/// **不要开 keepExif**。
|
||||||
|
class NativeMediaImageCompressor implements MediaImageCompressor {
|
||||||
|
const NativeMediaImageCompressor({this.maxLongEdge = 2048});
|
||||||
|
|
||||||
|
final int maxLongEdge;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CompressedMediaImage> compress(
|
||||||
|
PickedMediaImage source, {
|
||||||
|
required int quality,
|
||||||
|
}) async {
|
||||||
|
final bytes = await FlutterImageCompress.compressWithList(
|
||||||
|
source.bytes,
|
||||||
|
minWidth: maxLongEdge,
|
||||||
|
minHeight: maxLongEdge,
|
||||||
|
quality: quality,
|
||||||
|
format: CompressFormat.jpeg,
|
||||||
|
autoCorrectionAngle: true,
|
||||||
|
keepExif: false,
|
||||||
|
);
|
||||||
|
return CompressedMediaImage(bytes: bytes, mimeType: 'image/jpeg');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
/// 预签名直传失败。
|
||||||
|
///
|
||||||
|
/// - [statusCode] 为 null 表示网络层失败(断连/超时),可原凭据重试;
|
||||||
|
/// - 403 为存储侧拒绝签名(凭据过期/被改动),须重新 createUpload 换新凭据;
|
||||||
|
/// - 其余状态码按可重试处理(重试走完整重传)。
|
||||||
|
class MediaDirectUploadException implements Exception {
|
||||||
|
const MediaDirectUploadException({this.statusCode, required this.message});
|
||||||
|
|
||||||
|
final int? statusCode;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// 存储侧拒绝了凭据(签名过期/不符),重试前必须换新凭据。
|
||||||
|
bool get isCredentialRejected => statusCode == 403;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MediaDirectUploadException($statusCode): $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预签名 PUT 直传抽象(两步上传第二步的传输层)。
|
||||||
|
///
|
||||||
|
/// 不走业务信封、不带 Bearer 鉴权——鉴权就是 URL 里的签名本身;
|
||||||
|
/// [headers] 即 createUpload 返回的 requiredHeaders,必须原样携带
|
||||||
|
/// (Content-Type 已签进签名,改动即 403)。
|
||||||
|
abstract class MediaDirectUploadClient {
|
||||||
|
Future<void> put({
|
||||||
|
required String url,
|
||||||
|
required Map<String, String> headers,
|
||||||
|
required Uint8List bytes,
|
||||||
|
void Function(int sent, int total)? onProgress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于裸 Dio 的实现:独立实例,无 AuthInterceptor(预签名 URL 携带
|
||||||
|
/// Authorization 头会破坏 SigV4 校验),sendTimeout 按 10 MiB 弱网上限放宽。
|
||||||
|
class DioMediaDirectUploadClient implements MediaDirectUploadClient {
|
||||||
|
DioMediaDirectUploadClient({Dio? dio})
|
||||||
|
: _dio =
|
||||||
|
dio ??
|
||||||
|
Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 5),
|
||||||
|
sendTimeout: const Duration(seconds: 120),
|
||||||
|
receiveTimeout: const Duration(seconds: 30),
|
||||||
|
validateStatus: (_) => true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> put({
|
||||||
|
required String url,
|
||||||
|
required Map<String, String> headers,
|
||||||
|
required Uint8List bytes,
|
||||||
|
void Function(int sent, int total)? onProgress,
|
||||||
|
}) async {
|
||||||
|
final Response<Object?> response;
|
||||||
|
try {
|
||||||
|
response = await _dio.put<Object?>(
|
||||||
|
url,
|
||||||
|
data: Stream<Uint8List>.value(bytes),
|
||||||
|
options: Options(
|
||||||
|
headers: {...headers, Headers.contentLengthHeader: bytes.length},
|
||||||
|
),
|
||||||
|
onSendProgress: onProgress,
|
||||||
|
);
|
||||||
|
} on DioException catch (error) {
|
||||||
|
throw MediaDirectUploadException(message: '直传网络失败:${error.type.name}');
|
||||||
|
}
|
||||||
|
final status = response.statusCode ?? 0;
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
throw MediaDirectUploadException(
|
||||||
|
statusCode: status,
|
||||||
|
message: '存储侧拒绝直传:HTTP $status',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
/// 待上传的一张原始图(选择器产物,压缩前形态)。
|
||||||
|
///
|
||||||
|
/// 只持有内存字节:不落磁盘副本、不携带原始文件路径,配合压缩层剥离
|
||||||
|
/// EXIF(含 GPS)的隐私纪律(03 号评估 §4.1)。
|
||||||
|
class PickedMediaImage {
|
||||||
|
const PickedMediaImage({required this.bytes, this.name});
|
||||||
|
|
||||||
|
/// 原始字节(供压缩层消费与格内缩略预览)。
|
||||||
|
final Uint8List bytes;
|
||||||
|
|
||||||
|
/// 原始文件名(仅诊断用途,不参与上传——objectKey 由服务端生成)。
|
||||||
|
final String? name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图片选择抽象:MediaUploader 只依赖本接口,单测注入假实现,
|
||||||
|
/// widget 测试无需平台通道。
|
||||||
|
abstract class MediaImagePicker {
|
||||||
|
/// 拉起系统选择器,最多返回 [limit] 张;用户取消返回空列表。
|
||||||
|
Future<List<PickedMediaImage>> pickImages({required int limit});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于 image_picker 的系统选择器实现(03 号评估 §4.1 选型:官方维护、
|
||||||
|
/// pickMultiImage 多选,M3 不引入重型相册组件)。
|
||||||
|
class SystemMediaImagePicker implements MediaImagePicker {
|
||||||
|
SystemMediaImagePicker({ImagePicker? picker})
|
||||||
|
: _picker = picker ?? ImagePicker();
|
||||||
|
|
||||||
|
final ImagePicker _picker;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PickedMediaImage>> pickImages({required int limit}) async {
|
||||||
|
// 仅剩一个空位时用单选(pickMultiImage 的 limit 在部分平台要求 ≥2)。
|
||||||
|
final files = limit <= 1
|
||||||
|
? [?await _picker.pickImage(source: ImageSource.gallery)]
|
||||||
|
: await _picker.pickMultiImage(limit: limit);
|
||||||
|
final images = <PickedMediaImage>[];
|
||||||
|
for (final file in files.take(limit)) {
|
||||||
|
images.add(
|
||||||
|
PickedMediaImage(bytes: await file.readAsBytes(), name: file.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_compression.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_picking.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/post_analytics.dart';
|
||||||
|
|
||||||
|
/// 单张图的上传阶段(05 号规范 §3.3 四视觉态的底层状态模型)。
|
||||||
|
///
|
||||||
|
/// 生命周期:queued → compressing → uploading(progress) → confirming
|
||||||
|
/// → ready | failed(retryable?)。ready 是唯一可交付态——**assetId 只在
|
||||||
|
/// ready 态对外可见**(孤儿防护:未 confirm 的 asset 不得被引用)。
|
||||||
|
enum MediaItemPhase {
|
||||||
|
queued,
|
||||||
|
compressing,
|
||||||
|
uploading,
|
||||||
|
confirming,
|
||||||
|
ready,
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单张图的不可变状态快照(UI 消费;内部任务状态见 _UploadTask)。
|
||||||
|
@immutable
|
||||||
|
class MediaUploadItem {
|
||||||
|
const MediaUploadItem({
|
||||||
|
required this.localId,
|
||||||
|
required this.phase,
|
||||||
|
required this.previewBytes,
|
||||||
|
this.progress = 0,
|
||||||
|
this.assetId,
|
||||||
|
this.errorMessage,
|
||||||
|
this.retryable = false,
|
||||||
|
}) : assert(
|
||||||
|
(assetId != null) == (phase == MediaItemPhase.ready),
|
||||||
|
'assetId 与 ready 态严格绑定(孤儿防护)',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 本地稳定标识(重试/删除寻址用,与服务端无关)。
|
||||||
|
final int localId;
|
||||||
|
|
||||||
|
final MediaItemPhase phase;
|
||||||
|
|
||||||
|
/// 缩略预览字节(原图,UI 直接 Image.memory 渲染)。
|
||||||
|
final Uint8List previewBytes;
|
||||||
|
|
||||||
|
/// 直传进度 0..1(uploading 有意义;confirming 视作 1.0)。
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
/// ready 态的可引用 asset 标识;其余态恒为 null(构造期断言兜底)。
|
||||||
|
final String? assetId;
|
||||||
|
|
||||||
|
/// failed 态的用户可读原因。
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
/// failed 态是否可重试(false = 终态,如压缩后仍超限)。
|
||||||
|
final bool retryable;
|
||||||
|
|
||||||
|
bool get isReady => phase == MediaItemPhase.ready;
|
||||||
|
bool get isFailed => phase == MediaItemPhase.failed;
|
||||||
|
bool get isBusy => !isReady && !isFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内部任务:可变状态 + 生命周期旗标(cancelled 后一切在途结果作废)。
|
||||||
|
class _UploadTask {
|
||||||
|
_UploadTask({required this.localId, required this.source});
|
||||||
|
|
||||||
|
final int localId;
|
||||||
|
final PickedMediaImage source;
|
||||||
|
|
||||||
|
MediaItemPhase phase = MediaItemPhase.queued;
|
||||||
|
double progress = 0;
|
||||||
|
String? errorMessage;
|
||||||
|
bool retryable = false;
|
||||||
|
bool cancelled = false;
|
||||||
|
|
||||||
|
/// 本图第几次上传尝试(媒体三段埋点 attemptSeq,从 1 起;retry 递增)。
|
||||||
|
int attemptSeq = 1;
|
||||||
|
|
||||||
|
/// 本次尝试的 started 时刻(succeeded 的 durationMs 口径)。
|
||||||
|
DateTime? attemptStartedAt;
|
||||||
|
|
||||||
|
/// 压缩产物缓存(重试跳过重压缩)。
|
||||||
|
CompressedMediaImage? compressed;
|
||||||
|
|
||||||
|
/// confirm 成功前的服务端 assetId 只以管线局部变量存在,**不落任务
|
||||||
|
/// 状态、不出现在 [MediaUploadItem] 快照**——孤儿防护的结构保证;
|
||||||
|
/// confirm 通过后才写入 [readyAssetId]。
|
||||||
|
String? readyAssetId;
|
||||||
|
|
||||||
|
MediaUploadItem snapshot() => MediaUploadItem(
|
||||||
|
localId: localId,
|
||||||
|
phase: phase,
|
||||||
|
previewBytes: source.bytes,
|
||||||
|
progress: progress,
|
||||||
|
assetId: readyAssetId,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
retryable: retryable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 媒体上传编排器(T3-13,03 号评估 §4.3 冻结接口的定稿实现)。
|
||||||
|
///
|
||||||
|
/// 职责:选图 → 压缩(降质阶梯 80→60,仍超 10 MiB 拒绝为终态失败)→
|
||||||
|
/// createUpload → 预签名 PUT 直传(进度回调;Content-Type 按
|
||||||
|
/// requiredHeaders 原样携带)→ confirm → ready assetId 交付。
|
||||||
|
///
|
||||||
|
/// 语义要点:
|
||||||
|
/// - **顺序保持**:items 顺序 = 加入顺序 = position 语义;并发上传的
|
||||||
|
/// 完成先后不影响顺序([buildAttachRequests] 按当前列表序发号)。
|
||||||
|
/// - **单飞槽位**:至多 [maxConcurrentUploads] 张同时占用网络管线,
|
||||||
|
/// 单图失败不拖垮整批。
|
||||||
|
/// - **凭据过期**:PUT 前过期预检、存储侧 403 各触发一次自动
|
||||||
|
/// re-createUpload(换新 assetId 新凭据);再失败交给手动重试。
|
||||||
|
/// - **失败重试**:retry 复用压缩产物、从 createUpload 全新开始
|
||||||
|
/// (统一覆盖「对象未上传保持 uploading」与「内容不符置 failed 终态」
|
||||||
|
/// 两种服务端分支——旧 asset 弃引用,由服务端超时清理兜底)。
|
||||||
|
/// - **孤儿防护**:未 confirm 的 assetId 只以管线局部变量存在;
|
||||||
|
/// 对外可见的 [MediaUploadItem.assetId] 与 ready 态严格绑定(断言),
|
||||||
|
/// [buildAttachRequests] 仅在全员 ready 时可用。
|
||||||
|
/// - 预签名凭据只存内存、用完即弃,不持久化(既有纪律)。
|
||||||
|
/// - **媒体三段埋点**(T3-17):每次尝试恰一条 started,收敛为
|
||||||
|
/// succeeded / failed 各一条;`sizeBucket` 统一取原图字节数。
|
||||||
|
class MediaUploader extends ChangeNotifier {
|
||||||
|
MediaUploader({
|
||||||
|
required this._repository,
|
||||||
|
MediaImagePicker? picker,
|
||||||
|
MediaImageCompressor? compressor,
|
||||||
|
MediaDirectUploadClient? directUpload,
|
||||||
|
this._analytics,
|
||||||
|
this.maxImages = 9,
|
||||||
|
this.maxConcurrentUploads = 2,
|
||||||
|
this.maxByteSize = 10 * 1024 * 1024,
|
||||||
|
DateTime Function()? now,
|
||||||
|
}) : _picker = picker ?? SystemMediaImagePicker(),
|
||||||
|
_compressor = compressor ?? const NativeMediaImageCompressor(),
|
||||||
|
_directUpload = directUpload ?? DioMediaDirectUploadClient(),
|
||||||
|
_now = now ?? DateTime.now,
|
||||||
|
_slots = maxConcurrentUploads;
|
||||||
|
|
||||||
|
final CommunityRepository _repository;
|
||||||
|
final MediaImagePicker _picker;
|
||||||
|
final MediaImageCompressor _compressor;
|
||||||
|
final MediaDirectUploadClient _directUpload;
|
||||||
|
|
||||||
|
/// 媒体上传三段埋点(T3-17 接入;未注入即不上报)。
|
||||||
|
final PostAnalytics? _analytics;
|
||||||
|
|
||||||
|
final DateTime Function() _now;
|
||||||
|
|
||||||
|
/// 九宫格上限(05 号规范 §3.2)。
|
||||||
|
final int maxImages;
|
||||||
|
|
||||||
|
final int maxConcurrentUploads;
|
||||||
|
|
||||||
|
/// 与服务端 byteSize 上限一致(10 MiB,13 号报告偏差 #7)。
|
||||||
|
final int maxByteSize;
|
||||||
|
|
||||||
|
/// 凭据过期预检安全边距:距 expiresAt 不足此值即视为过期,直接换新。
|
||||||
|
static const credentialsSafetyMargin = Duration(seconds: 30);
|
||||||
|
|
||||||
|
/// 压缩降质阶梯(80 常规 → 60 兜底;仍超限即终态拒绝)。
|
||||||
|
static const qualityLadder = [80, 60];
|
||||||
|
|
||||||
|
final List<_UploadTask> _tasks = [];
|
||||||
|
int _nextLocalId = 1;
|
||||||
|
bool _picking = false;
|
||||||
|
|
||||||
|
int _slots;
|
||||||
|
final List<Completer<void>> _slotWaiters = [];
|
||||||
|
|
||||||
|
// ---- 对外状态 ----
|
||||||
|
|
||||||
|
/// 选择器是否拉起中(uploader 级 picking 态)。
|
||||||
|
bool get isPicking => _picking;
|
||||||
|
|
||||||
|
List<MediaUploadItem> get items =>
|
||||||
|
List.unmodifiable(_tasks.map((task) => task.snapshot()));
|
||||||
|
|
||||||
|
bool get isEmpty => _tasks.isEmpty;
|
||||||
|
int get remainingSlots => maxImages - _tasks.length;
|
||||||
|
int get readyCount =>
|
||||||
|
_tasks.where((t) => t.phase == MediaItemPhase.ready).length;
|
||||||
|
bool get allReady => _tasks.isNotEmpty && readyCount == _tasks.length;
|
||||||
|
bool get hasFailure => _tasks.any((t) => t.phase == MediaItemPhase.failed);
|
||||||
|
bool get hasBusyItem => _picking || _tasks.any((t) => t.snapshot().isBusy);
|
||||||
|
|
||||||
|
/// 页级汇总进度(05 号规范 §3.3 线性进度条):各图等权。
|
||||||
|
double get overallProgress {
|
||||||
|
if (_tasks.isEmpty) return 0;
|
||||||
|
var sum = 0.0;
|
||||||
|
for (final task in _tasks) {
|
||||||
|
sum += switch (task.phase) {
|
||||||
|
MediaItemPhase.ready => 1.0,
|
||||||
|
MediaItemPhase.uploading => task.progress,
|
||||||
|
MediaItemPhase.confirming => 1.0,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return sum / _tasks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 交付口(T3-17 发布页组 CreatePostRequest.media 用):
|
||||||
|
/// 仅全员 ready 时可用——**任何非 ready 项在场即抛 [StateError]**,
|
||||||
|
/// 从类型上杜绝未 confirm asset 被引用。position 按当前列表序 0..n-1。
|
||||||
|
List<PostMediaAttachRequest> buildAttachRequests({int coverIndex = 0}) {
|
||||||
|
if (!allReady) {
|
||||||
|
throw StateError('存在未就绪的上传项,不得引用(孤儿防护)');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final (index, task) in _tasks.indexed)
|
||||||
|
PostMediaAttachRequest(
|
||||||
|
assetId: task.readyAssetId!,
|
||||||
|
position: index,
|
||||||
|
isCover: index == coverIndex,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 操作 ----
|
||||||
|
|
||||||
|
/// 拉起系统选择器并把所选图片加入上传管线(剩余槽位自动截断)。
|
||||||
|
Future<void> pickAndAdd() async {
|
||||||
|
if (_picking || remainingSlots <= 0) return;
|
||||||
|
_picking = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final images = await _picker.pickImages(limit: remainingSlots);
|
||||||
|
_picking = false;
|
||||||
|
addImages(images);
|
||||||
|
} catch (_) {
|
||||||
|
_picking = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 直接加入图片(选择器旁路,测试与分享外链场景用)。
|
||||||
|
void addImages(List<PickedMediaImage> images) {
|
||||||
|
for (final image in images.take(remainingSlots)) {
|
||||||
|
final task = _UploadTask(localId: _nextLocalId++, source: image);
|
||||||
|
_tasks.add(task);
|
||||||
|
unawaited(_run(task));
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重试一张可重试失败图:复用压缩产物,从 createUpload 全新开始。
|
||||||
|
void retry(int localId) {
|
||||||
|
final task = _taskOrNull(localId);
|
||||||
|
if (task == null ||
|
||||||
|
task.phase != MediaItemPhase.failed ||
|
||||||
|
!task.retryable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
task.errorMessage = null;
|
||||||
|
task.retryable = false;
|
||||||
|
task.progress = 0;
|
||||||
|
task.attemptSeq += 1;
|
||||||
|
task.phase = MediaItemPhase.queued;
|
||||||
|
notifyListeners();
|
||||||
|
unawaited(_run(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 移除一张图(任意态可移除);在途请求结果一律作废,未 confirm 的
|
||||||
|
/// 服务端 asset 弃引用(服务端超时清理兜底)。在途任务被移除按
|
||||||
|
/// `cancelled` 上报一条上传失败(06 §1.4「用户取消」口径)。
|
||||||
|
void remove(int localId) {
|
||||||
|
final task = _taskOrNull(localId);
|
||||||
|
if (task == null) return;
|
||||||
|
_reportCancelled(task);
|
||||||
|
task.cancelled = true;
|
||||||
|
_tasks.remove(task);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空全部(发布成功/离开页面时调用);在途任务同 [remove] 记 cancelled。
|
||||||
|
void reset() {
|
||||||
|
for (final task in _tasks) {
|
||||||
|
_reportCancelled(task);
|
||||||
|
task.cancelled = true;
|
||||||
|
}
|
||||||
|
_tasks.clear();
|
||||||
|
_picking = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
_UploadTask? _taskOrNull(int localId) {
|
||||||
|
for (final task in _tasks) {
|
||||||
|
if (task.localId == localId) return task;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 管线 ----
|
||||||
|
|
||||||
|
Future<void> _run(_UploadTask task) async {
|
||||||
|
await _acquireSlot();
|
||||||
|
try {
|
||||||
|
if (task.cancelled) return;
|
||||||
|
// 一次尝试恰一条 started(含压缩段:压缩失败也在漏斗内可见)。
|
||||||
|
task.attemptStartedAt = _now();
|
||||||
|
_analytics?.mediaUploadStarted(
|
||||||
|
mediaType: MediaType.image,
|
||||||
|
byteSize: task.source.bytes.length,
|
||||||
|
);
|
||||||
|
final compressed = await _compress(task);
|
||||||
|
if (compressed == null || task.cancelled) return;
|
||||||
|
await _uploadAndConfirm(task, compressed);
|
||||||
|
} finally {
|
||||||
|
_releaseSlot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 压缩(降质阶梯);超限终态失败返回 null。
|
||||||
|
Future<CompressedMediaImage?> _compress(_UploadTask task) async {
|
||||||
|
final cached = task.compressed;
|
||||||
|
if (cached != null) return cached;
|
||||||
|
_transition(task, MediaItemPhase.compressing);
|
||||||
|
try {
|
||||||
|
for (final quality in qualityLadder) {
|
||||||
|
final result = await _compressor.compress(
|
||||||
|
task.source,
|
||||||
|
quality: quality,
|
||||||
|
);
|
||||||
|
if (task.cancelled) return null;
|
||||||
|
if (result.byteSize <= maxByteSize) {
|
||||||
|
task.compressed = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: '图片处理失败',
|
||||||
|
retryable: true,
|
||||||
|
reason: MediaUploadFailureReason.unsupportedFormat,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: '图片过大,压缩后仍超过 10 MB',
|
||||||
|
retryable: false,
|
||||||
|
reason: MediaUploadFailureReason.mediaTooLarge,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadAndConfirm(
|
||||||
|
_UploadTask task,
|
||||||
|
CompressedMediaImage compressed,
|
||||||
|
) async {
|
||||||
|
// createUpload:登记 mime/byteSize,取预签名 PUT 凭据(内存态,不持久化)。
|
||||||
|
MediaUploadCredentials credentials;
|
||||||
|
try {
|
||||||
|
credentials = await _createUpload(compressed);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
if (!task.cancelled) _failFromApi(task, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task.cancelled) return;
|
||||||
|
|
||||||
|
// 直传 PUT:过期预检与存储侧 403 各允许一次自动换新凭据。
|
||||||
|
_transition(task, MediaItemPhase.uploading);
|
||||||
|
var renewed = false;
|
||||||
|
while (true) {
|
||||||
|
if (_credentialsExpired(credentials)) {
|
||||||
|
if (renewed) {
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: '上传凭据已过期',
|
||||||
|
retryable: true,
|
||||||
|
reason: MediaUploadFailureReason.serverError,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renewed = true;
|
||||||
|
try {
|
||||||
|
credentials = await _createUpload(compressed);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
if (!task.cancelled) _failFromApi(task, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task.cancelled) return;
|
||||||
|
// 回到循环顶复检新凭据(服务端时钟异常仍过期即失败,不无限重取)。
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _directUpload.put(
|
||||||
|
url: credentials.uploadUrl,
|
||||||
|
headers: credentials.requiredHeaders,
|
||||||
|
bytes: compressed.bytes,
|
||||||
|
onProgress: (sent, total) {
|
||||||
|
if (task.cancelled || total <= 0) return;
|
||||||
|
task.progress = sent / total;
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} on MediaDirectUploadException catch (error) {
|
||||||
|
if (task.cancelled) return;
|
||||||
|
if (error.isCredentialRejected && !renewed) {
|
||||||
|
renewed = true;
|
||||||
|
try {
|
||||||
|
credentials = await _createUpload(compressed);
|
||||||
|
} on Exception catch (creationError) {
|
||||||
|
_failFromApi(task, creationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task.cancelled) return;
|
||||||
|
task.progress = 0;
|
||||||
|
notifyListeners();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: error.statusCode == null ? '网络中断,上传失败' : '上传被存储服务拒绝',
|
||||||
|
retryable: true,
|
||||||
|
reason: error.statusCode == null
|
||||||
|
? MediaUploadFailureReason.networkError
|
||||||
|
: MediaUploadFailureReason.serverError,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (task.cancelled) return;
|
||||||
|
|
||||||
|
// confirm:42205 对象未上传保持 uploading / 内容不符置 failed 终态,
|
||||||
|
// 客户端统一按「可重试 + 重试换新 asset」处理,两分支都正确收敛。
|
||||||
|
_transition(task, MediaItemPhase.confirming);
|
||||||
|
final MediaAsset asset;
|
||||||
|
try {
|
||||||
|
asset = await _repository.completeMediaUpload(credentials.assetId);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
if (!task.cancelled) _failFromApi(task, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task.cancelled) return;
|
||||||
|
if (asset.status != MediaAssetStatus.ready) {
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: '上传确认未通过',
|
||||||
|
retryable: true,
|
||||||
|
reason: MediaUploadFailureReason.serverError,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
task.readyAssetId = asset.id;
|
||||||
|
task.progress = 1;
|
||||||
|
_transition(task, MediaItemPhase.ready);
|
||||||
|
final startedAt = task.attemptStartedAt;
|
||||||
|
_analytics?.mediaUploadSucceeded(
|
||||||
|
mediaType: MediaType.image,
|
||||||
|
byteSize: task.source.bytes.length,
|
||||||
|
durationMs: startedAt == null
|
||||||
|
? 0
|
||||||
|
: _now().difference(startedAt).inMilliseconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<MediaUploadCredentials> _createUpload(
|
||||||
|
CompressedMediaImage compressed,
|
||||||
|
) {
|
||||||
|
return _repository.createMediaUpload(
|
||||||
|
CreateMediaUploadRequest(
|
||||||
|
kind: MediaKind.image,
|
||||||
|
purpose: MediaPurpose.postImage,
|
||||||
|
mimeType: compressed.mimeType,
|
||||||
|
byteSize: compressed.byteSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _credentialsExpired(MediaUploadCredentials credentials) =>
|
||||||
|
!_now().add(credentialsSafetyMargin).isBefore(credentials.expiresAt);
|
||||||
|
|
||||||
|
void _failFromApi(_UploadTask task, Exception error) {
|
||||||
|
// 参数被服务端拒绝(40000:mime/byteSize 白名单外)重试无意义,终态。
|
||||||
|
final isParamError =
|
||||||
|
error is ApiBusinessException && error.code == ApiCodes.paramError;
|
||||||
|
_fail(
|
||||||
|
task,
|
||||||
|
message: error is ApiBusinessException ? error.message : '网络异常,请重试',
|
||||||
|
retryable: !isParamError,
|
||||||
|
// 客户端已本地保证 ≤10 MiB,故 40000 归因为格式白名单外;
|
||||||
|
// 会话失效不上报(reason 传 null),其余业务/限流并入 server_error。
|
||||||
|
reason: switch (error) {
|
||||||
|
ApiBusinessException _ when isParamError =>
|
||||||
|
MediaUploadFailureReason.unsupportedFormat,
|
||||||
|
SessionExpiredException _ => null,
|
||||||
|
ApiNetworkException _ => MediaUploadFailureReason.networkError,
|
||||||
|
_ => MediaUploadFailureReason.serverError,
|
||||||
|
},
|
||||||
|
errorCode: error is ApiBusinessException ? error.code : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fail(
|
||||||
|
_UploadTask task, {
|
||||||
|
required String message,
|
||||||
|
required bool retryable,
|
||||||
|
required MediaUploadFailureReason? reason,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
if (task.cancelled) return;
|
||||||
|
task.phase = MediaItemPhase.failed;
|
||||||
|
task.errorMessage = message;
|
||||||
|
task.retryable = retryable;
|
||||||
|
if (reason != null) {
|
||||||
|
_analytics?.mediaUploadFailed(
|
||||||
|
mediaType: MediaType.image,
|
||||||
|
byteSize: task.source.bytes.length,
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: task.attemptSeq,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在途任务被删格/清空作废 → `cancelled`(已 ready / 已 failed 不报)。
|
||||||
|
void _reportCancelled(_UploadTask task) {
|
||||||
|
if (task.cancelled || !task.snapshot().isBusy) return;
|
||||||
|
_analytics?.mediaUploadFailed(
|
||||||
|
mediaType: MediaType.image,
|
||||||
|
byteSize: task.source.bytes.length,
|
||||||
|
reason: MediaUploadFailureReason.cancelled,
|
||||||
|
attemptSeq: task.attemptSeq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _transition(_UploadTask task, MediaItemPhase phase) {
|
||||||
|
if (task.cancelled) return;
|
||||||
|
task.phase = phase;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _acquireSlot() {
|
||||||
|
if (_slots > 0) {
|
||||||
|
_slots--;
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
final waiter = Completer<void>();
|
||||||
|
_slotWaiters.add(waiter);
|
||||||
|
return waiter.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _releaseSlot() {
|
||||||
|
if (_slotWaiters.isNotEmpty) {
|
||||||
|
_slotWaiters.removeAt(0).complete();
|
||||||
|
} else {
|
||||||
|
_slots++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [MediaUploader] 的构造口(发布页每次进入建一个,退出即 dispose)。
|
||||||
|
///
|
||||||
|
/// 生产缺省即 `MediaUploader(repository: ..., analytics: ...)`;注入点为
|
||||||
|
/// **测试与桌面实测专用**——Linux 桌面既无 image_picker 也无
|
||||||
|
/// flutter_image_compress 的原生实现,桌面真链路只替换选图与压缩两层,
|
||||||
|
/// 其余(createUpload / 直传 PUT / confirm)全为生产实现。
|
||||||
|
typedef MediaUploaderFactory =
|
||||||
|
MediaUploader Function(
|
||||||
|
CommunityRepository repository,
|
||||||
|
PostAnalytics? analytics,
|
||||||
|
);
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'
|
||||||
|
show textLengthBucketOf;
|
||||||
|
|
||||||
|
/// post 域埋点强类型封装(06 号规划 §1.4 发布漏斗五事件 + 媒体上传三段;
|
||||||
|
/// 后端白名单随 api dev@`8089c06` 就绪,22 号报告 §1 键集逐一对齐)。
|
||||||
|
/// 沿 pet/feed/互动域惯例:枚举编译期锁死,业务代码禁止手拼事件名与属性。
|
||||||
|
///
|
||||||
|
/// 隐私纪律(06 §1.3 红线):正文只出分桶不出字数(红线 1);postId /
|
||||||
|
/// assetId 等内容 ID 一律不进 props(红线 2);媒体只报 [MediaType] 与
|
||||||
|
/// [mediaSizeBucketOf] 分桶,文件名/路径/URL 禁止(红线 4)。
|
||||||
|
///
|
||||||
|
/// **不得上报**(22 号 §1 末段锁死为 unknown):`post_impression`、
|
||||||
|
/// `post_viewed`、`post_like_failed` 等——本文件不提供其封装。
|
||||||
|
|
||||||
|
/// 发帖入口(06 §1.4 `post_create_started.entryPoint`)。
|
||||||
|
/// M3 接 create_tab / feed;topic_detail / pet_detail 随后续页面启用。
|
||||||
|
enum PostEntryPoint {
|
||||||
|
createTab('create_tab'),
|
||||||
|
feed('feed'),
|
||||||
|
topicDetail('topic_detail'),
|
||||||
|
petDetail('pet_detail');
|
||||||
|
|
||||||
|
const PostEntryPoint(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 草稿保存触发方式(06 §1.4:**自动保存不埋**,防高频)。
|
||||||
|
enum DraftSaveTrigger {
|
||||||
|
/// 「存草稿」按钮显式保存。
|
||||||
|
manual('manual'),
|
||||||
|
|
||||||
|
/// 离开发布页时经「保留草稿?」确认保存。
|
||||||
|
onExit('on_exit');
|
||||||
|
|
||||||
|
const DraftSaveTrigger(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布失败原因(06 §1.4 枚举;`content_rejected` 待拍板未启用,
|
||||||
|
/// `not_found` 取 §1.4「失败枚举基底」的复用条——草稿已被别处删除)。
|
||||||
|
enum PostPublishFailureReason {
|
||||||
|
validationError('validation_error'),
|
||||||
|
mediaUploadIncomplete('media_upload_incomplete'),
|
||||||
|
notFound('not_found'),
|
||||||
|
rateLimited('rate_limited'),
|
||||||
|
networkError('network_error'),
|
||||||
|
serverError('server_error');
|
||||||
|
|
||||||
|
const PostPublishFailureReason(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 媒体类型(M3 仅 image;video 随视频能力启用)。
|
||||||
|
enum MediaType {
|
||||||
|
image('image'),
|
||||||
|
video('video');
|
||||||
|
|
||||||
|
const MediaType(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单文件上传失败原因(06 §1.4 媒体漏斗枚举)。
|
||||||
|
enum MediaUploadFailureReason {
|
||||||
|
mediaTooLarge('media_too_large'),
|
||||||
|
unsupportedFormat('unsupported_format'),
|
||||||
|
networkError('network_error'),
|
||||||
|
serverError('server_error'),
|
||||||
|
|
||||||
|
/// 用户在上传途中删格 / 离开发布页作废在途任务。
|
||||||
|
cancelled('cancelled');
|
||||||
|
|
||||||
|
const MediaUploadFailureReason(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 类型化异常 → 发布失败原因;会话失效返回 null(应用即将回登录页,
|
||||||
|
/// 不作为发布失败上报,feed / 互动域同款口径)。
|
||||||
|
///
|
||||||
|
/// 映射取舍(26 号报告 §3 有对照表):42203 恰为
|
||||||
|
/// [PostPublishFailureReason.mediaUploadIncomplete];40905(同键异
|
||||||
|
/// payload)归 validation_error——提交内容与幂等键不一致属提交侧问题,
|
||||||
|
/// 非服务端故障;40902(乐观锁,自动刷新 version 重提仍失败)归
|
||||||
|
/// server_error 兜底。
|
||||||
|
PostPublishFailureReason? postPublishFailureReasonOf(ApiException error) =>
|
||||||
|
switch (error) {
|
||||||
|
ApiNetworkException _ => PostPublishFailureReason.networkError,
|
||||||
|
ApiRateLimitException _ => PostPublishFailureReason.rateLimited,
|
||||||
|
SessionExpiredException _ => null,
|
||||||
|
ApiBusinessException(:final code) => switch (code) {
|
||||||
|
ApiCodes.paramError || ApiCodes.idempotencyKeyMismatch =>
|
||||||
|
PostPublishFailureReason.validationError,
|
||||||
|
ApiCodes.mediaNotReady =>
|
||||||
|
PostPublishFailureReason.mediaUploadIncomplete,
|
||||||
|
ApiCodes.postNotFound ||
|
||||||
|
ApiCodes.mediaNotFound => PostPublishFailureReason.notFound,
|
||||||
|
_ => PostPublishFailureReason.serverError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 媒体大小分桶(06 §1.3 红线 4:不报精确字节数)。
|
||||||
|
/// `lt_1mb` / `mb_1_5` / `mb_5_20` / `gte_20mb`,以 MiB 为界
|
||||||
|
/// (与 MediaUploader 的 10 MiB 上限同一进制)。
|
||||||
|
String mediaSizeBucketOf(int byteSize) {
|
||||||
|
const mib = 1024 * 1024;
|
||||||
|
if (byteSize < mib) return 'lt_1mb';
|
||||||
|
if (byteSize < 5 * mib) return 'mb_1_5';
|
||||||
|
if (byteSize < 20 * mib) return 'mb_5_20';
|
||||||
|
return 'gte_20mb';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布漏斗 + 媒体上传三段埋点(22 号白名单 v3 事件 22~29)。
|
||||||
|
class PostAnalytics {
|
||||||
|
PostAnalytics(this._track);
|
||||||
|
|
||||||
|
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
|
||||||
|
final TrackEventFn _track;
|
||||||
|
|
||||||
|
// ---- 发布漏斗 ----
|
||||||
|
|
||||||
|
/// 进入发布页并产生**首次输入**(首个字符或首次选媒体),每次进入记一次。
|
||||||
|
/// 草稿恢复不算输入(非用户动作,不上报)。
|
||||||
|
void postCreateStarted({required PostEntryPoint entryPoint}) {
|
||||||
|
_track('post_create_started', {'entryPoint': entryPoint.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 草稿保存**成功响应后**;仅显式保存与离开时保存(自动保存不埋)。
|
||||||
|
void postDraftSaved({
|
||||||
|
required DraftSaveTrigger trigger,
|
||||||
|
required int mediaCount,
|
||||||
|
}) {
|
||||||
|
_track('post_draft_saved', {
|
||||||
|
'trigger': trigger.value,
|
||||||
|
'mediaCount': mediaCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布成功响应后(漏斗事件,H5/H6 核心数据源)。
|
||||||
|
///
|
||||||
|
/// [durationMs]:`post_create_started` → 发布成功;[textLength] 经
|
||||||
|
/// [textLengthBucketOf] 分桶后上报,精确字数不出端;[fromDraft] 指
|
||||||
|
/// 「本次发布基于先前保存/恢复的草稿」(发布内部的建草稿→迁移两步
|
||||||
|
/// 不算,见 26 号报告 §3)。
|
||||||
|
void postPublishSucceeded({
|
||||||
|
required int durationMs,
|
||||||
|
required int mediaCount,
|
||||||
|
required int topicCount,
|
||||||
|
required int textLength,
|
||||||
|
required bool fromDraft,
|
||||||
|
}) {
|
||||||
|
_track('post_publish_succeeded', {
|
||||||
|
'durationMs': durationMs,
|
||||||
|
'mediaCount': mediaCount,
|
||||||
|
'topicCount': topicCount,
|
||||||
|
'textLengthBucket': textLengthBucketOf(textLength),
|
||||||
|
'fromDraft': fromDraft,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布失败 / 超时 / 本地校验拦截。
|
||||||
|
///
|
||||||
|
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位业务码
|
||||||
|
/// 推导(`code ~/ 100`,pet 域同款);[attemptSeq] 为本次发布会话内第几次
|
||||||
|
/// 尝试(从 1 起,发布成功或离开发布页后重置)。
|
||||||
|
void postPublishFailed({
|
||||||
|
required PostPublishFailureReason reason,
|
||||||
|
required int attemptSeq,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('post_publish_failed', {
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'attemptSeq': attemptSeq,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删帖成功响应后(单事件风格,无专有属性;失败靠服务端错误率观测)。
|
||||||
|
/// M3 触点:发布页「不保留草稿」删除服务端草稿。
|
||||||
|
void postDeleted() {
|
||||||
|
_track('post_deleted', const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 媒体上传三段(逐文件)----
|
||||||
|
|
||||||
|
/// 单个文件开始上传(一次尝试恰一条;重试各记一条)。
|
||||||
|
///
|
||||||
|
/// [byteSize] 取**选图原文件**字节数,保证同一次尝试三段事件的
|
||||||
|
/// `sizeBucket` 一致(压缩产物大小不另开一套桶)。
|
||||||
|
void mediaUploadStarted({
|
||||||
|
required MediaType mediaType,
|
||||||
|
required int byteSize,
|
||||||
|
}) {
|
||||||
|
_track('post_media_upload_started', {
|
||||||
|
'mediaType': mediaType.value,
|
||||||
|
'sizeBucket': mediaSizeBucketOf(byteSize),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单文件上传成功(confirm 返回 ready 后)。
|
||||||
|
/// [durationMs]:本次尝试 started → ready。
|
||||||
|
void mediaUploadSucceeded({
|
||||||
|
required MediaType mediaType,
|
||||||
|
required int byteSize,
|
||||||
|
required int durationMs,
|
||||||
|
}) {
|
||||||
|
_track('post_media_upload_succeeded', {
|
||||||
|
'mediaType': mediaType.value,
|
||||||
|
'sizeBucket': mediaSizeBucketOf(byteSize),
|
||||||
|
'durationMs': durationMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单文件失败 / 超时 / 用户取消。
|
||||||
|
void mediaUploadFailed({
|
||||||
|
required MediaType mediaType,
|
||||||
|
required int byteSize,
|
||||||
|
required MediaUploadFailureReason reason,
|
||||||
|
required int attemptSeq,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('post_media_upload_failed', {
|
||||||
|
'mediaType': mediaType.value,
|
||||||
|
'sizeBucket': mediaSizeBucketOf(byteSize),
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'attemptSeq': attemptSeq,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,704 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/post_analytics.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// 发布页(P3,05 号规范 §2.3;T3-17 真实数据整页落地)。
|
||||||
|
///
|
||||||
|
/// 结构:媒体选择区([PostMediaEditGrid] 组装 [MediaUploader])→ 正文
|
||||||
|
/// → 类目(general / help)→ 位置占位;AppBar 三件套「取消 / 发布动态 /
|
||||||
|
/// 发布」,另置「存草稿」。
|
||||||
|
///
|
||||||
|
/// 两条提交路径(15 号后端语义 §2.4):
|
||||||
|
///
|
||||||
|
/// - **直接发布** = `createPost(status: draft)` 建草稿 → `PATCH
|
||||||
|
/// {status: published}` 迁移发布。两步而非「一步建 published」是为了让
|
||||||
|
/// 「发布失败但草稿已保存」成为事实而不是话术:迁移这一步失败时草稿
|
||||||
|
/// 已在服务端,用户内容不会丢。
|
||||||
|
/// - **存草稿退出** = 同一个 `createPost(status: draft)`(或对已有草稿
|
||||||
|
/// `PATCH`),随后离页。
|
||||||
|
///
|
||||||
|
/// 幂等纪律:建草稿的 `Idempotency-Key` 由**本页持有**——网络失败重试
|
||||||
|
/// 沿用同键(服务端命中首帖,不重复建帖);表单一经改动即换新键
|
||||||
|
/// (避免「同键异 payload」的 40905 常态化)。
|
||||||
|
///
|
||||||
|
/// 草稿管理最小实现:进页拉取「我的草稿」最新一条并恢复(05 §2.3 的
|
||||||
|
/// 「已恢复上次草稿」提示条),完整草稿列表页留待(26 号报告 §7)。
|
||||||
|
class PostComposePage extends StatefulWidget {
|
||||||
|
const PostComposePage({
|
||||||
|
required this.controller,
|
||||||
|
super.key,
|
||||||
|
this.entryPoint = PostEntryPoint.createTab,
|
||||||
|
this.analytics,
|
||||||
|
this.uploaderFactory,
|
||||||
|
this.now,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tab 级单例(app.dart 装配):发布成功后由调用方触发 Feed 刷新。
|
||||||
|
final CommunityController controller;
|
||||||
|
|
||||||
|
/// 入口归因(`post_create_started.entryPoint`)。
|
||||||
|
final PostEntryPoint entryPoint;
|
||||||
|
|
||||||
|
/// post 域埋点(发布漏斗五事件 + 媒体三段,媒体段经 [MediaUploader])。
|
||||||
|
final PostAnalytics? analytics;
|
||||||
|
|
||||||
|
/// [MediaUploader] 构造口(测试 / 桌面实测替换选图与压缩层)。
|
||||||
|
final MediaUploaderFactory? uploaderFactory;
|
||||||
|
|
||||||
|
/// 时钟注入口(durationMs 断言用;缺省取当前时间)。
|
||||||
|
final DateTime Function()? now;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PostComposePage> createState() => _PostComposePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PostComposePageState extends State<PostComposePage> {
|
||||||
|
static const _maxContentLength = 1000;
|
||||||
|
|
||||||
|
final _contentController = TextEditingController();
|
||||||
|
final _uuid = const Uuid();
|
||||||
|
|
||||||
|
late final MediaUploader _uploader;
|
||||||
|
|
||||||
|
PostCategory _category = PostCategory.general;
|
||||||
|
|
||||||
|
/// 服务端草稿标识与乐观锁版本(建草稿成功或恢复草稿后非空)。
|
||||||
|
String? _draftPostId;
|
||||||
|
int? _draftVersion;
|
||||||
|
|
||||||
|
/// 恢复来的草稿既有媒体(uploader 只持本地选图,服务端媒体只读呈现)。
|
||||||
|
List<PostMediaItem> _draftMedia = const [];
|
||||||
|
|
||||||
|
/// 「已恢复上次草稿」提示条可见性。
|
||||||
|
bool _restoredBannerVisible = false;
|
||||||
|
|
||||||
|
/// 草稿恢复期间的输入监听抑制(恢复不是「首次输入」,不发 started)。
|
||||||
|
bool _restoring = false;
|
||||||
|
|
||||||
|
/// 本页是否基于先前保存/恢复的草稿发布(`fromDraft` 口径)。
|
||||||
|
bool _fromDraft = false;
|
||||||
|
|
||||||
|
/// 上一次同步到服务端的 ready assetId 签名(media 三态判定:
|
||||||
|
/// 与当前一致即 PATCH 缺席不动,不一致才整组替换)。
|
||||||
|
String? _syncedMediaSignature;
|
||||||
|
|
||||||
|
/// 建草稿幂等键(同键重放;表单改动即置 null 换新键)。
|
||||||
|
String? _idempotencyKey;
|
||||||
|
|
||||||
|
bool _publishing = false;
|
||||||
|
bool _savingDraft = false;
|
||||||
|
|
||||||
|
/// 发布失败横幅(页内停留供对照,不用 SnackBar)。
|
||||||
|
String? _publishErrorMessage;
|
||||||
|
|
||||||
|
/// 「草稿已保存」的伴随提示(发布失败时告知内容未丢)。
|
||||||
|
bool _draftPreservedHint = false;
|
||||||
|
|
||||||
|
/// 「已保存草稿 ✓」提示(保存动作后显示)。
|
||||||
|
bool _draftSavedHint = false;
|
||||||
|
|
||||||
|
/// 首次输入已上报 started。
|
||||||
|
bool _started = false;
|
||||||
|
DateTime? _startedAt;
|
||||||
|
|
||||||
|
/// 本页发布尝试序号(attemptSeq,从 1 起)。
|
||||||
|
int _publishAttemptSeq = 0;
|
||||||
|
|
||||||
|
CommunityController get _controller => widget.controller;
|
||||||
|
|
||||||
|
DateTime _nowValue() => (widget.now ?? DateTime.now)();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_uploader = (widget.uploaderFactory ?? _defaultUploaderFactory)(
|
||||||
|
_controller.repository,
|
||||||
|
widget.analytics,
|
||||||
|
);
|
||||||
|
_uploader.addListener(_onUploaderChanged);
|
||||||
|
_contentController.addListener(_onContentChanged);
|
||||||
|
unawaited(_restoreLatestDraft());
|
||||||
|
}
|
||||||
|
|
||||||
|
static MediaUploader _defaultUploaderFactory(
|
||||||
|
CommunityRepository repository,
|
||||||
|
PostAnalytics? analytics,
|
||||||
|
) => MediaUploader(repository: repository, analytics: analytics);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_uploader.removeListener(_onUploaderChanged);
|
||||||
|
// 在途上传作废(未 confirm 的 asset 弃引用,服务端超时清理兜底)。
|
||||||
|
_uploader.reset();
|
||||||
|
_uploader.dispose();
|
||||||
|
_contentController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 输入与状态 ----
|
||||||
|
|
||||||
|
String get _content => _contentController.text.trim();
|
||||||
|
|
||||||
|
bool get _isEmptyForm =>
|
||||||
|
_content.isEmpty && _uploader.isEmpty && _draftMedia.isEmpty;
|
||||||
|
|
||||||
|
/// 发布 gating(05 §2.2/§2.3 + 后端 content 必填):正文非空、
|
||||||
|
/// 在场媒体全部 ready、无在途提交。
|
||||||
|
bool get _canPublish =>
|
||||||
|
_content.isNotEmpty &&
|
||||||
|
(_uploader.isEmpty || _uploader.allReady) &&
|
||||||
|
!_publishing &&
|
||||||
|
!_savingDraft;
|
||||||
|
|
||||||
|
void _onContentChanged() {
|
||||||
|
if (_restoring) return;
|
||||||
|
_markDirty();
|
||||||
|
_reportStartedOnce();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onUploaderChanged() {
|
||||||
|
if (_uploader.items.isNotEmpty) _reportStartedOnce();
|
||||||
|
_markDirty();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 表单一经改动即弃用旧幂等键(下次提交换新键,杜绝 40905 常态化)。
|
||||||
|
void _markDirty() {
|
||||||
|
_idempotencyKey = null;
|
||||||
|
_draftSavedHint = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reportStartedOnce() {
|
||||||
|
if (_started) return;
|
||||||
|
if (_content.isEmpty && _uploader.isEmpty) return;
|
||||||
|
_started = true;
|
||||||
|
_startedAt = _nowValue();
|
||||||
|
widget.analytics?.postCreateStarted(entryPoint: widget.entryPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 草稿恢复(最小实现:最新一条)----
|
||||||
|
|
||||||
|
Future<void> _restoreLatestDraft() async {
|
||||||
|
try {
|
||||||
|
final page = await _controller.repository.listMyPosts(
|
||||||
|
limit: 1,
|
||||||
|
status: PostStatus.draft,
|
||||||
|
);
|
||||||
|
if (!mounted || page.items.isEmpty) return;
|
||||||
|
final draft = page.items.first;
|
||||||
|
_restoring = true;
|
||||||
|
_contentController.text = draft.content;
|
||||||
|
_restoring = false;
|
||||||
|
setState(() {
|
||||||
|
_draftPostId = draft.id;
|
||||||
|
_draftVersion = draft.version;
|
||||||
|
_draftMedia = draft.media;
|
||||||
|
// ai_creation(M4 预留读侧值)不在发布页可选集内,回落 general。
|
||||||
|
_category = draft.category == PostCategory.aiCreation
|
||||||
|
? PostCategory.general
|
||||||
|
: draft.category;
|
||||||
|
_restoredBannerVisible = true;
|
||||||
|
_fromDraft = true;
|
||||||
|
_syncedMediaSignature = _mediaSignature();
|
||||||
|
});
|
||||||
|
} on ApiException {
|
||||||
|
// 草稿恢复失败静默降级为「新建」,不阻塞发布(不打扰)。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearRestoredDraft() {
|
||||||
|
_restoring = true;
|
||||||
|
_contentController.clear();
|
||||||
|
_restoring = false;
|
||||||
|
setState(() {
|
||||||
|
_draftMedia = const [];
|
||||||
|
_restoredBannerVisible = false;
|
||||||
|
_idempotencyKey = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 媒体 ----
|
||||||
|
|
||||||
|
Future<void> _pickImages() async {
|
||||||
|
await _uploader.pickAndAdd();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_uploader.remainingSlots <= 0) {
|
||||||
|
_showSnackBar('最多可选 ${_uploader.maxImages} 张图片');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _mediaSignature() => _uploader.items
|
||||||
|
.where((item) => item.isReady)
|
||||||
|
.map((item) => item.assetId)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
/// 当前选图的挂接请求(全 ready 才可取;封面取首张)。
|
||||||
|
List<PostMediaAttachRequest>? _mediaAttachOrNull() =>
|
||||||
|
_uploader.isEmpty ? null : _uploader.buildAttachRequests();
|
||||||
|
|
||||||
|
// ---- 发布(建草稿 → 迁移发布)----
|
||||||
|
|
||||||
|
Future<void> _publish() async {
|
||||||
|
if (!_canPublish) return;
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
_publishAttemptSeq += 1;
|
||||||
|
setState(() {
|
||||||
|
_publishing = true;
|
||||||
|
_publishErrorMessage = null;
|
||||||
|
_draftPreservedHint = false;
|
||||||
|
});
|
||||||
|
final signature = _mediaSignature();
|
||||||
|
final fromDraft = _fromDraft && _draftPostId != null;
|
||||||
|
try {
|
||||||
|
if (_draftPostId == null) {
|
||||||
|
final key = _idempotencyKey ??= _uuid.v4();
|
||||||
|
final draft = await _controller.repository.createPost(
|
||||||
|
CreatePostRequest(
|
||||||
|
content: _content,
|
||||||
|
category: _category,
|
||||||
|
status: PostStatus.draft,
|
||||||
|
media: _mediaAttachOrNull(),
|
||||||
|
),
|
||||||
|
idempotencyKey: key,
|
||||||
|
);
|
||||||
|
_draftPostId = draft.id;
|
||||||
|
_draftVersion = draft.version;
|
||||||
|
_syncedMediaSignature = signature;
|
||||||
|
}
|
||||||
|
await _patchPublish(signature);
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.analytics?.postPublishSucceeded(
|
||||||
|
durationMs: _elapsedSinceStart(),
|
||||||
|
mediaCount: _uploader.items.length + _keptDraftMediaCount(signature),
|
||||||
|
// 话题(TopicChip / 话题选择 sheet)无契约端点,M3 恒 0。
|
||||||
|
topicCount: 0,
|
||||||
|
textLength: _content.length,
|
||||||
|
fromDraft: fromDraft,
|
||||||
|
);
|
||||||
|
_uploader.reset();
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_handlePublishError(error);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _publishing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH 迁移发布;乐观锁过期(40902,别处改过草稿)自动刷新 version
|
||||||
|
/// 重提一次。已发布帖重复提交为幂等 no-op(15 号 §2.4),弱网重放安全。
|
||||||
|
Future<void> _patchPublish(String signature) async {
|
||||||
|
final media = signature == _syncedMediaSignature
|
||||||
|
? null // 缺席不动(服务端媒体与本地选图一致)
|
||||||
|
: _mediaAttachOrNull() ?? const <PostMediaAttachRequest>[];
|
||||||
|
UpdatePostRequest request(int version) => UpdatePostRequest(
|
||||||
|
version: version,
|
||||||
|
content: _content,
|
||||||
|
category: _category,
|
||||||
|
publish: true,
|
||||||
|
media: media,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await _controller.repository.updatePost(
|
||||||
|
_draftPostId!,
|
||||||
|
request(_draftVersion!),
|
||||||
|
);
|
||||||
|
} on PostVersionConflictException {
|
||||||
|
final latest = await _controller.repository.getPost(_draftPostId!);
|
||||||
|
_draftVersion = latest.version;
|
||||||
|
await _controller.repository.updatePost(
|
||||||
|
_draftPostId!,
|
||||||
|
request(latest.version),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_syncedMediaSignature = signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 恢复草稿的服务端既有媒体在本次发布中被保留的张数(mediaCount 口径)。
|
||||||
|
int _keptDraftMediaCount(String signature) =>
|
||||||
|
signature == _syncedMediaSignature && _uploader.isEmpty
|
||||||
|
? _draftMedia.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
int _elapsedSinceStart() {
|
||||||
|
final startedAt = _startedAt;
|
||||||
|
if (startedAt == null) return 0;
|
||||||
|
return _nowValue().difference(startedAt).inMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handlePublishError(ApiException error) {
|
||||||
|
final reason = postPublishFailureReasonOf(error);
|
||||||
|
if (reason != null) {
|
||||||
|
widget.analytics?.postPublishFailed(
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: _publishAttemptSeq,
|
||||||
|
errorCode: error is ApiBusinessException ? error.code : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error is IdempotencyMismatchException) {
|
||||||
|
// 同键异 payload:弃用旧键,下次提交换新键即可成功。
|
||||||
|
_idempotencyKey = null;
|
||||||
|
}
|
||||||
|
if (error is PostNotFoundException) {
|
||||||
|
// 草稿在别处被删:解除关联,重试走全新建草稿。
|
||||||
|
_draftPostId = null;
|
||||||
|
_draftVersion = null;
|
||||||
|
_fromDraft = false;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_publishErrorMessage = postPublishErrorMessage(error);
|
||||||
|
// 草稿已在服务端 → 明确告知内容未丢(本单核心提示语义)。
|
||||||
|
_draftPreservedHint = _draftPostId != null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 存草稿 ----
|
||||||
|
|
||||||
|
Future<bool> _saveDraft(DraftSaveTrigger trigger) async {
|
||||||
|
if (_isEmptyForm) return true;
|
||||||
|
if (_content.isEmpty) {
|
||||||
|
_showSnackBar('请先写点什么再保存草稿');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_uploader.hasBusyItem) {
|
||||||
|
_showSnackBar('图片还在上传中,请稍候再保存草稿');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_uploader.hasFailure) {
|
||||||
|
_showSnackBar('有图片上传失败,请重试或删除后再保存草稿');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_savingDraft = true;
|
||||||
|
_publishErrorMessage = null;
|
||||||
|
});
|
||||||
|
final signature = _mediaSignature();
|
||||||
|
try {
|
||||||
|
if (_draftPostId == null) {
|
||||||
|
final key = _idempotencyKey ??= _uuid.v4();
|
||||||
|
final draft = await _controller.repository.createPost(
|
||||||
|
CreatePostRequest(
|
||||||
|
content: _content,
|
||||||
|
category: _category,
|
||||||
|
status: PostStatus.draft,
|
||||||
|
media: _mediaAttachOrNull(),
|
||||||
|
),
|
||||||
|
idempotencyKey: key,
|
||||||
|
);
|
||||||
|
_draftPostId = draft.id;
|
||||||
|
_draftVersion = draft.version;
|
||||||
|
} else {
|
||||||
|
final updated = await _controller.repository.updatePost(
|
||||||
|
_draftPostId!,
|
||||||
|
UpdatePostRequest(
|
||||||
|
version: _draftVersion!,
|
||||||
|
content: _content,
|
||||||
|
category: _category,
|
||||||
|
media: signature == _syncedMediaSignature
|
||||||
|
? null
|
||||||
|
: _mediaAttachOrNull() ?? const <PostMediaAttachRequest>[],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_draftVersion = updated.version;
|
||||||
|
}
|
||||||
|
_syncedMediaSignature = signature;
|
||||||
|
_fromDraft = true;
|
||||||
|
widget.analytics?.postDraftSaved(
|
||||||
|
trigger: trigger,
|
||||||
|
mediaCount: _uploader.items.length + _keptDraftMediaCount(signature),
|
||||||
|
);
|
||||||
|
if (mounted) setState(() => _draftSavedHint = true);
|
||||||
|
return true;
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (mounted) _showSnackBar(draftSaveErrorMessage(error));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _savingDraft = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「不保留」:已落服务端的草稿一并软删(`post_deleted` 触点)。
|
||||||
|
Future<void> _discardDraft() async {
|
||||||
|
final draftId = _draftPostId;
|
||||||
|
if (draftId == null) return;
|
||||||
|
try {
|
||||||
|
await _controller.repository.deletePost(draftId);
|
||||||
|
widget.analytics?.postDeleted();
|
||||||
|
} on ApiException {
|
||||||
|
// 删除失败不拦住离页(草稿留在服务端,下次进页可恢复)。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 离页 ----
|
||||||
|
|
||||||
|
Future<void> _onCancel() async {
|
||||||
|
if (_publishing || _savingDraft) return;
|
||||||
|
if (_isEmptyForm) {
|
||||||
|
Navigator.of(context).pop(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final choice = await showDialog<_ExitChoice>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('保留草稿?'),
|
||||||
|
content: const Text('保留后下次进入发布页可继续编辑。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, _ExitChoice.keepEditing),
|
||||||
|
child: const Text('继续编辑'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, _ExitChoice.discard),
|
||||||
|
child: const Text('不保留'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, _ExitChoice.keep),
|
||||||
|
child: const Text('保留'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted || choice == null || choice == _ExitChoice.keepEditing) return;
|
||||||
|
if (choice == _ExitChoice.discard) {
|
||||||
|
await _discardDraft();
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pop(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final saved = await _saveDraft(DraftSaveTrigger.onExit);
|
||||||
|
if (!mounted || !saved) return;
|
||||||
|
Navigator.of(context).pop(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 渲染 ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return PopScope(
|
||||||
|
canPop: _isEmptyForm && !_publishing && !_savingDraft,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (!didPop) unawaited(_onCancel());
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leadingWidth: 76,
|
||||||
|
leading: Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _publishing ? null : () => unawaited(_onCancel()),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.ink),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: const Text('发布动态'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _publishing || _savingDraft
|
||||||
|
? null
|
||||||
|
: () => unawaited(_saveDraft(DraftSaveTrigger.manual)),
|
||||||
|
child: const Text('存草稿'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 40),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
),
|
||||||
|
onPressed: _canPublish ? () => unawaited(_publish()) : null,
|
||||||
|
child: _publishing
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('发布'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||||
|
children: [
|
||||||
|
if (_restoredBannerVisible) ...[
|
||||||
|
_RestoredDraftBanner(onClear: _clearRestoredDraft),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (_publishErrorMessage != null) ...[
|
||||||
|
InlineErrorBanner(message: _publishErrorMessage!),
|
||||||
|
if (_draftPreservedHint) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
const Text(
|
||||||
|
'草稿已保存,可稍后继续发布',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.inkSoft),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (_draftSavedHint) ...[
|
||||||
|
const Text(
|
||||||
|
'已保存草稿 ✓',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.inkSoft),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
..._mediaSection(),
|
||||||
|
if (_uploader.hasBusyItem) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_UploadSummaryBar(
|
||||||
|
progress: _uploader.overallProgress,
|
||||||
|
readyCount: _uploader.readyCount,
|
||||||
|
total: _uploader.items.length,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _contentController,
|
||||||
|
minLines: 6,
|
||||||
|
maxLines: null,
|
||||||
|
maxLength: _maxContentLength,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '分享毛孩子的日常,或向宠友求助…',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('分类', style: theme.textTheme.bodySmall),
|
||||||
|
const SizedBox(height: 7),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final entry in const [
|
||||||
|
(PostCategory.general, '日常分享'),
|
||||||
|
(PostCategory.help, '求助'),
|
||||||
|
])
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(entry.$2),
|
||||||
|
selected: _category == entry.$1,
|
||||||
|
onSelected: (_) {
|
||||||
|
_markDirty();
|
||||||
|
setState(() => _category = entry.$1);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.location_on_outlined),
|
||||||
|
title: const Text('添加位置(选填)'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _showSnackBar('位置功能即将上线'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _mediaSection() {
|
||||||
|
final showDraftMedia = _uploader.isEmpty && _draftMedia.isNotEmpty;
|
||||||
|
return [
|
||||||
|
PostMediaEditGrid(
|
||||||
|
items: _uploader.items,
|
||||||
|
canAdd: _uploader.remainingSlots > 0,
|
||||||
|
onAdd: _uploader.isPicking ? null : () => unawaited(_pickImages()),
|
||||||
|
onRemove: _uploader.remove,
|
||||||
|
onRetry: _uploader.retry,
|
||||||
|
),
|
||||||
|
if (showDraftMedia) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'草稿已含 ${_draftMedia.length} 张图片(发布时保留;重新选图将整组替换)',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.inkSoft),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _ExitChoice { keep, discard, keepEditing }
|
||||||
|
|
||||||
|
/// 「已恢复上次草稿」提示条(05 §2.3:surfaceTint 底、圆角 sm12)。
|
||||||
|
class _RestoredDraftBanner extends StatelessWidget {
|
||||||
|
const _RestoredDraftBanner({required this.onClear});
|
||||||
|
|
||||||
|
final VoidCallback onClear;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 4, 4, 4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.surfaceTint,
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(AppRadius.sm)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'已恢复上次草稿',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.primaryDark),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: onClear, child: const Text('清空')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 页级上传汇总条(05 §3.3 末段:线性进度 + 「正在上传 n/N」)。
|
||||||
|
class _UploadSummaryBar extends StatelessWidget {
|
||||||
|
const _UploadSummaryBar({
|
||||||
|
required this.progress,
|
||||||
|
required this.readyCount,
|
||||||
|
required this.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double progress;
|
||||||
|
final int readyCount;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'正在上传 $readyCount/$total',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.inkSoft),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
minHeight: 4,
|
||||||
|
color: AppColors.primaryStrong,
|
||||||
|
backgroundColor: AppColors.surfaceTint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 服务端返回的开关权威终态(LikeState / BookmarkState 的统一投影)。
|
||||||
|
class ToggleOutcome {
|
||||||
|
const ToggleOutcome({required this.active, required this.count});
|
||||||
|
|
||||||
|
final bool active;
|
||||||
|
final int count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内存副本当前读数(乐观翻转与回滚校验的基准)。
|
||||||
|
class ToggleReading {
|
||||||
|
const ToggleReading({required this.active, required this.count});
|
||||||
|
|
||||||
|
final bool active;
|
||||||
|
final int count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞 / 收藏共用的乐观更新小状态机(03 号评估 §3 定稿的数据层部分):
|
||||||
|
/// **乐观翻转 + 快照回滚 + 单飞合并意图 + 代次守卫**。字段读写与端点
|
||||||
|
/// 全部参数化,like / bookmark 各持一实例,不复制两份逻辑。
|
||||||
|
///
|
||||||
|
/// 对每个 id 的一轮「操作链」:
|
||||||
|
/// 1. 点击立即经 [write] 翻转内存副本(UI 同帧反馈由持有方 notify);
|
||||||
|
/// 2. 非在途则记快照、发请求(PUT/DELETE 语义幂等,重放安全);
|
||||||
|
/// 在途则只把新意图并入 pendingTarget,**不发新请求**(单飞);
|
||||||
|
/// 3. 成功:pendingTarget 与已确认态不一致 → 以 pendingTarget 补发一次
|
||||||
|
/// (连续快速点击至多两个在途请求,中间抖动全被合并);一致 → 用服务端
|
||||||
|
/// 权威计数覆盖乐观计数(吸收他人并发造成的偏差),清状态;
|
||||||
|
/// 4. 失败:恢复链起点快照(先校验 id 仍可读且当前态仍是本轮乐观目标,
|
||||||
|
/// 避免覆盖新数据),经 [onError] 轻提示,**不自动重试**;
|
||||||
|
/// 5. 代次守卫:响应到达时 [generation] 与链起点不符(期间发生过刷新,
|
||||||
|
/// 列表已被服务端数据整体替换)→ 丢弃该响应,不覆盖不回滚。
|
||||||
|
class ToggleSync {
|
||||||
|
ToggleSync({
|
||||||
|
required this._read,
|
||||||
|
required this._write,
|
||||||
|
required this._send,
|
||||||
|
required this._generation,
|
||||||
|
this._onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ToggleReading? Function(String id) _read;
|
||||||
|
final void Function(String id, bool active, int count) _write;
|
||||||
|
final Future<ToggleOutcome> Function(String id, bool target) _send;
|
||||||
|
final int Function() _generation;
|
||||||
|
final void Function(String id, Object error)? _onError;
|
||||||
|
|
||||||
|
final Map<String, _ToggleChain> _chains = {};
|
||||||
|
|
||||||
|
/// 该 id 是否有请求在途(测试与调试观测口)。
|
||||||
|
bool isInFlight(String id) => _chains.containsKey(id);
|
||||||
|
|
||||||
|
/// 翻转一次。同步完成乐观写入;网络往返在后台收敛,不外抛。
|
||||||
|
void toggle(String id) {
|
||||||
|
final current = _read(id);
|
||||||
|
if (current == null) return; // 已不在列表(刷新剔除),本次点击作废。
|
||||||
|
final target = !current.active;
|
||||||
|
final optimisticCount = target
|
||||||
|
? current.count + 1
|
||||||
|
: (current.count - 1 < 0 ? 0 : current.count - 1);
|
||||||
|
_write(id, target, optimisticCount);
|
||||||
|
|
||||||
|
final chain = _chains[id];
|
||||||
|
if (chain != null) {
|
||||||
|
chain.pendingTarget = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final started = _ToggleChain(
|
||||||
|
snapshot: current,
|
||||||
|
generation: _generation(),
|
||||||
|
target: target,
|
||||||
|
);
|
||||||
|
_chains[id] = started;
|
||||||
|
unawaited(_run(id, started));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登出 / 整体刷新清态:丢弃全部链(在途响应因代次或链失配被丢弃)。
|
||||||
|
void reset() => _chains.clear();
|
||||||
|
|
||||||
|
Future<void> _run(String id, _ToggleChain chain) async {
|
||||||
|
while (true) {
|
||||||
|
ToggleOutcome outcome;
|
||||||
|
try {
|
||||||
|
outcome = await _send(id, chain.target);
|
||||||
|
} catch (error) {
|
||||||
|
if (_chains[id] == chain && _generation() == chain.generation) {
|
||||||
|
final current = _read(id);
|
||||||
|
final lastTarget = chain.pendingTarget ?? chain.target;
|
||||||
|
// 回滚前校验:id 仍可读且当前态仍是本轮乐观写入的目标态。
|
||||||
|
if (current != null && current.active == lastTarget) {
|
||||||
|
_write(id, chain.snapshot.active, chain.snapshot.count);
|
||||||
|
}
|
||||||
|
_onError?.call(id, error);
|
||||||
|
}
|
||||||
|
_release(id, chain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_chains[id] != chain || _generation() != chain.generation) {
|
||||||
|
_release(id, chain);
|
||||||
|
return; // 代次不符 / 已被 reset:丢弃响应,不覆盖不回滚。
|
||||||
|
}
|
||||||
|
|
||||||
|
final pending = chain.pendingTarget;
|
||||||
|
if (pending != null && pending != outcome.active) {
|
||||||
|
chain.target = pending;
|
||||||
|
chain.pendingTarget = null;
|
||||||
|
continue; // 以最终意图补发一次。
|
||||||
|
}
|
||||||
|
|
||||||
|
_write(id, outcome.active, outcome.count);
|
||||||
|
_release(id, chain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _release(String id, _ToggleChain chain) {
|
||||||
|
if (_chains[id] == chain) _chains.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一轮操作链的在途状态。
|
||||||
|
class _ToggleChain {
|
||||||
|
_ToggleChain({
|
||||||
|
required this.snapshot,
|
||||||
|
required this.generation,
|
||||||
|
required this.target,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 链起点快照(回滚基准)。
|
||||||
|
final ToggleReading snapshot;
|
||||||
|
|
||||||
|
/// 链起点的刷新代次。
|
||||||
|
final int generation;
|
||||||
|
|
||||||
|
/// 当前在途请求的目标态。
|
||||||
|
bool target;
|
||||||
|
|
||||||
|
/// 在途期间用户新点出的最终意图(完成后据此决定是否补发)。
|
||||||
|
bool? pendingTarget;
|
||||||
|
}
|
||||||
@@ -7,15 +7,23 @@ import 'package:patbond_flutter/widgets/common.dart';
|
|||||||
|
|
||||||
enum CreationMode { image, video }
|
enum CreationMode { image, video }
|
||||||
|
|
||||||
|
/// 创作 Tab。
|
||||||
|
///
|
||||||
|
/// **AI 生成模拟(700/650/500ms 假延时、风格/模型/分辨率设置、结果卡)
|
||||||
|
/// 属 M4 范围,T3-17 原样保留**;社区发布半边自 T3-17 起改由真实发布页
|
||||||
|
/// (`PostComposePage`,push 全屏)承担——本页顶部「发布动态」入口即其
|
||||||
|
/// 入口(entryPoint=create_tab),`AppState.publishPost` demo 发布流退役。
|
||||||
class CreatePage extends StatefulWidget {
|
class CreatePage extends StatefulWidget {
|
||||||
const CreatePage({
|
const CreatePage({
|
||||||
required this.appState,
|
required this.appState,
|
||||||
required this.onPublished,
|
required this.onOpenCompose,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppState appState;
|
final AppState appState;
|
||||||
final ValueChanged<PostModel> onPublished;
|
|
||||||
|
/// 真实发布页入口(主壳 push,发布成功后回首页 Feed 刷新)。
|
||||||
|
final VoidCallback onOpenCompose;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CreatePage> createState() => _CreatePageState();
|
State<CreatePage> createState() => _CreatePageState();
|
||||||
@@ -111,36 +119,13 @@ class _CreatePageState extends State<CreatePage> {
|
|||||||
setState(() => tags = [...tags, value]);
|
setState(() => tags = [...tags, value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 作品的社区发布留待 M4:AI 结果是生成图(无本地文件、无 media
|
||||||
|
/// asset),走不了两步上传,故不接真实发布链路;demo 发布流(写
|
||||||
|
/// `AppState.posts`)随 T3-17 退役,此处只余占位提示。
|
||||||
void publish() {
|
void publish() {
|
||||||
if (resultUrl == null || titleController.text.trim().isEmpty) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
ScaffoldMessenger.of(
|
const SnackBar(content: Text('AI 作品发布随 AI 创作能力上线(M4);发布普通动态请用上方「发布动态」')),
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('请先完成生成并填写标题')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final post = PostModel(
|
|
||||||
id: 'post_user_${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
authorName: '萌宠新手(我)',
|
|
||||||
authorAvatar: userAvatar,
|
|
||||||
time: '刚刚',
|
|
||||||
breedTag: 'AI创作',
|
|
||||||
content:
|
|
||||||
'${titleController.text.trim()}\n\n${contentController.text.trim()}',
|
|
||||||
mainImage: resultUrl!,
|
|
||||||
likes: 1,
|
|
||||||
tags: [...tags, mode == CreationMode.image ? 'AI生图' : 'AI视频'],
|
|
||||||
comments: const [],
|
|
||||||
hasLiked: true,
|
|
||||||
);
|
);
|
||||||
widget.appState.publishPost(post);
|
|
||||||
setState(() {
|
|
||||||
uploaded = false;
|
|
||||||
resultUrl = null;
|
|
||||||
generationStep = 0;
|
|
||||||
titleController.clear();
|
|
||||||
contentController.clear();
|
|
||||||
});
|
|
||||||
widget.onPublished(post);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -148,6 +133,8 @@ class _CreatePageState extends State<CreatePage> {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||||
children: [
|
children: [
|
||||||
|
_ComposeEntryCard(onTap: widget.onOpenCompose),
|
||||||
|
const SizedBox(height: 18),
|
||||||
SegmentedButton<CreationMode>(
|
SegmentedButton<CreationMode>(
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
@@ -402,6 +389,35 @@ class _CreatePageState extends State<CreatePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 真实发布入口(T3-17):本页其余部分是 AI 创作模拟(M4),社区发帖
|
||||||
|
/// 走这里 push 的发布页。
|
||||||
|
class _ComposeEntryCard extends StatelessWidget {
|
||||||
|
const _ComposeEntryCard({required this.onTap});
|
||||||
|
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SectionCard(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
backgroundColor: AppColors.surfaceTint,
|
||||||
|
child: Icon(Icons.edit_outlined, color: AppColors.primary),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'发布动态',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
subtitle: const Text('写点文字、配上照片,分享给宠友'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _UploadCard extends StatelessWidget {
|
class _UploadCard extends StatelessWidget {
|
||||||
const _UploadCard({
|
const _UploadCard({
|
||||||
required this.uploaded,
|
required this.uploaded,
|
||||||
|
|||||||
+463
-210
@@ -1,42 +1,252 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/feed_skeleton.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/post_card.dart';
|
||||||
import 'package:patbond_flutter/data/demo_data.dart';
|
import 'package:patbond_flutter/data/demo_data.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/feed_exposure.dart';
|
||||||
import 'package:patbond_flutter/models/models.dart';
|
import 'package:patbond_flutter/models/models.dart';
|
||||||
import 'package:patbond_flutter/state/app_state.dart';
|
import 'package:patbond_flutter/state/app_state.dart';
|
||||||
import 'package:patbond_flutter/widgets/common.dart';
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
enum HomeSegment { feed, services }
|
enum HomeSegment { feed, services }
|
||||||
|
|
||||||
|
/// 首页 Tab:Feed 段自 T3-14 起消费 [CommunityController] 真实数据
|
||||||
|
/// (四态 + 游标翻页 + 聚合曝光埋点);天气条/问候卡/搜索/服务段与
|
||||||
|
/// `_StoryRow` 家具保留 demo 形态(03 号评估 §1.1 判定)。
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
const HomePage({
|
const HomePage({
|
||||||
required this.appState,
|
required this.appState,
|
||||||
required this.onOpenPost,
|
required this.communityController,
|
||||||
required this.onOpenServices,
|
required this.onOpenServices,
|
||||||
required this.onOpenCreate,
|
required this.onOpenCompose,
|
||||||
super.key,
|
super.key,
|
||||||
|
this.onOpenPost,
|
||||||
|
this.feedAnalytics,
|
||||||
|
this.isActive = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppState appState;
|
final AppState appState;
|
||||||
final ValueChanged<PostModel> onOpenPost;
|
|
||||||
|
/// Feed 数据源(Tab 级单例,app.dart 装配注入)。
|
||||||
|
final CommunityController communityController;
|
||||||
|
|
||||||
final ValueChanged<bool> onOpenServices;
|
final ValueChanged<bool> onOpenServices;
|
||||||
final VoidCallback onOpenCreate;
|
|
||||||
|
/// 发布页入口(T3-17:story 环「发布」与空态 CTA 均 push 真实发布页,
|
||||||
|
/// entryPoint=feed;创作 Tab 的 AI 模拟不再是社区发帖入口)。
|
||||||
|
final VoidCallback onOpenCompose;
|
||||||
|
|
||||||
|
/// 帖子详情导航(T3-15 接通;主壳 push PostDetailPage)。
|
||||||
|
final ValueChanged<String>? onOpenPost;
|
||||||
|
|
||||||
|
/// feed 域埋点(feed_viewed 聚合曝光 + feed_load_failed)。
|
||||||
|
final FeedAnalytics? feedAnalytics;
|
||||||
|
|
||||||
|
/// 首页 Tab 是否为当前可见 Tab(IndexedStack 各 Tab 常驻构建,
|
||||||
|
/// 由主壳传入以驱动浏览段开/结算)。
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HomePage> createState() => _HomePageState();
|
State<HomePage> createState() => _HomePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||||
HomeSegment segment = HomeSegment.feed;
|
HomeSegment segment = HomeSegment.feed;
|
||||||
String query = '';
|
String query = '';
|
||||||
String sort = '综合';
|
String sort = '综合';
|
||||||
|
|
||||||
List<PostModel> get filteredPosts {
|
/// 当前 Feed 浏览段(不在 Feed 面上时为 null)。
|
||||||
|
FeedViewSegment? _viewSegment;
|
||||||
|
|
||||||
|
/// 曝光扫描用:postId → 卡片 GlobalKey。
|
||||||
|
final Map<String, GlobalKey> _cardKeys = {};
|
||||||
|
final GlobalKey _listKey = GlobalKey();
|
||||||
|
|
||||||
|
AppLifecycleState _lastLifecycle = AppLifecycleState.resumed;
|
||||||
|
|
||||||
|
CommunityController get _feed => widget.communityController;
|
||||||
|
|
||||||
|
bool get _feedSurfaceVisible =>
|
||||||
|
widget.isActive && segment == HomeSegment.feed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
// 主壳挂载即预取(pets 先例);重登后控制器已 reset 回 initial。
|
||||||
|
// 首屏自动预取不计入浏览段 refreshCount(非用户动作)。
|
||||||
|
if (_feed.phase == FeedPhase.initial) {
|
||||||
|
_refreshFeed(userInitiated: false);
|
||||||
|
}
|
||||||
|
if (_feedSurfaceVisible) _startSegment();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(HomePage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.isActive != widget.isActive) {
|
||||||
|
widget.isActive ? _maybeStartSegment() : _settleSegment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// 退后台即结算浏览段(06 §1.2:feed_viewed 触发点之一);只在离开
|
||||||
|
// resumed 的第一次变更结算(inactive/hidden/paused 级联不重复)。
|
||||||
|
// 回前台若仍在 Feed 面上则开新段。
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_lastLifecycle = state;
|
||||||
|
_maybeStartSegment();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_lastLifecycle == AppLifecycleState.resumed) {
|
||||||
|
_settleSegment();
|
||||||
|
}
|
||||||
|
_lastLifecycle = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_settleSegment();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 浏览段开/结算 ----
|
||||||
|
|
||||||
|
void _startSegment() {
|
||||||
|
_viewSegment ??= FeedViewSegment();
|
||||||
|
_scheduleVisibilityScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _maybeStartSegment() {
|
||||||
|
if (_feedSurfaceVisible && _lastLifecycle == AppLifecycleState.resumed) {
|
||||||
|
_startSegment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _settleSegment() {
|
||||||
|
final summary = _viewSegment?.settle();
|
||||||
|
_viewSegment = null;
|
||||||
|
if (summary != null && widget.feedAnalytics != null) {
|
||||||
|
summary.report(widget.feedAnalytics!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSegmentChanged(HomeSegment value) {
|
||||||
|
if (value == segment) return;
|
||||||
|
setState(() => segment = value);
|
||||||
|
value == HomeSegment.feed ? _maybeStartSegment() : _settleSegment();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 曝光可见性扫描 ----
|
||||||
|
|
||||||
|
bool _scanScheduled = false;
|
||||||
|
|
||||||
|
/// 帧末扫描(滚动通知发生在本帧布局前,同步读 RenderBox 是旧位置;
|
||||||
|
/// 统一调度到 post-frame,一帧至多一次)。
|
||||||
|
void _scheduleVisibilityScan() {
|
||||||
|
if (_scanScheduled) return;
|
||||||
|
_scanScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_scanScheduled = false;
|
||||||
|
_scanVisibility();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 以列表视口与各卡片 RenderBox 的纵向交叠比例回报可见性
|
||||||
|
/// (≥50% 且驻留 ≥500ms 才计曝光,判定在 [FeedViewSegment] 内)。
|
||||||
|
void _scanVisibility() {
|
||||||
|
final viewSegment = _viewSegment;
|
||||||
|
if (viewSegment == null || !mounted) return;
|
||||||
|
final listBox = _listKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (listBox == null || !listBox.attached) return;
|
||||||
|
final viewportTop = listBox.localToGlobal(Offset.zero).dy;
|
||||||
|
final viewportBottom = viewportTop + listBox.size.height;
|
||||||
|
for (final entry in _cardKeys.entries) {
|
||||||
|
final box = entry.value.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
var fraction = 0.0;
|
||||||
|
if (box != null && box.attached && box.hasSize && box.size.height > 0) {
|
||||||
|
final top = box.localToGlobal(Offset.zero).dy;
|
||||||
|
final bottom = top + box.size.height;
|
||||||
|
final visible =
|
||||||
|
bottom.clamp(viewportTop, viewportBottom) -
|
||||||
|
top.clamp(viewportTop, viewportBottom);
|
||||||
|
fraction = visible / box.size.height;
|
||||||
|
}
|
||||||
|
viewSegment.updateVisibility(entry.key, fraction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _onScrollNotification(ScrollNotification notification) {
|
||||||
|
if (segment != HomeSegment.feed) return false;
|
||||||
|
if (notification is ScrollUpdateNotification ||
|
||||||
|
notification is ScrollEndNotification) {
|
||||||
|
_scheduleVisibilityScan();
|
||||||
|
// 滚动近底触发翻页(余量 400 提前预取);失败态不自动重试,
|
||||||
|
// 只走尾部重试条显式点按(避免滚动风暴反复打失败端点)。
|
||||||
|
if (notification.metrics.extentAfter < 400 &&
|
||||||
|
_feed.loadMorePhase == LoadMorePhase.idle) {
|
||||||
|
_loadMoreFeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 数据加载与失败埋点 ----
|
||||||
|
|
||||||
|
Future<void> _refreshFeed({required bool userInitiated}) async {
|
||||||
|
if (userInitiated) _viewSegment?.recordRefresh();
|
||||||
|
await _feed.refresh();
|
||||||
|
final error = _feed.refreshError ?? _feed.lastError;
|
||||||
|
if (error != null) {
|
||||||
|
widget.feedAnalytics?.feedLoadFailedFrom(
|
||||||
|
error,
|
||||||
|
feedTab: FeedTab.home,
|
||||||
|
loadType: FeedLoadType.refresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 刷新失败但旧列表被保留:SnackBar 轻提示(首屏失败走 error 态横幅)。
|
||||||
|
if (_feed.refreshError != null && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(feedLoadErrorMessage(_feed.refreshError))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMoreFeed() async {
|
||||||
|
if (_feed.phase != FeedPhase.ready ||
|
||||||
|
!_feed.hasMore ||
|
||||||
|
_feed.loadMorePhase == LoadMorePhase.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_viewSegment?.recordLoadMore();
|
||||||
|
await _feed.loadMore();
|
||||||
|
final error = _feed.loadMoreError;
|
||||||
|
if (error != null) {
|
||||||
|
widget.feedAnalytics?.feedLoadFailedFrom(
|
||||||
|
error,
|
||||||
|
feedTab: FeedTab.home,
|
||||||
|
loadType: FeedLoadType.loadMore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 搜索词过滤(demo 交互保留:只过滤已加载的多页缓存,不发检索请求;
|
||||||
|
/// 契约 v1.3.0 无搜索端点)。
|
||||||
|
List<FeedCard> get _visibleCards {
|
||||||
final keyword = query.trim().toLowerCase();
|
final keyword = query.trim().toLowerCase();
|
||||||
if (keyword.isEmpty) return widget.appState.posts;
|
if (keyword.isEmpty) return _feed.feed;
|
||||||
return widget.appState.posts.where((post) {
|
return _feed.feed.where((card) {
|
||||||
return post.content.toLowerCase().contains(keyword) ||
|
return card.contentPreview.toLowerCase().contains(keyword) ||
|
||||||
post.authorName.toLowerCase().contains(keyword) ||
|
(card.title?.toLowerCase().contains(keyword) ?? false) ||
|
||||||
post.tags.any((tag) => tag.toLowerCase().contains(keyword));
|
authorDisplayName(card.author).toLowerCase().contains(keyword);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,113 +305,250 @@ class _HomePageState extends State<HomePage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RefreshIndicator(
|
return ListenableBuilder(
|
||||||
onRefresh: () async =>
|
listenable: _feed,
|
||||||
Future<void>.delayed(const Duration(milliseconds: 500)),
|
builder: (context, _) {
|
||||||
child: ListView(
|
// 点赞/收藏对账失败的一次性 SnackBar(§3.5 回滚提示;与详情页
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
// 共用 controller 的 toggleError 消费口,先消费者清空)。
|
||||||
children: [
|
if (_feed.toggleError != null) {
|
||||||
_WeatherStatusBar(
|
_feed.clearToggleError();
|
||||||
weather: widget.appState.locationWeather,
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
onAreaTap: selectArea,
|
if (!mounted) return;
|
||||||
onWeatherTap: showWeatherDetails,
|
ScaffoldMessenger.of(
|
||||||
),
|
context,
|
||||||
const SizedBox(height: 12),
|
).showSnackBar(const SnackBar(content: Text('操作失败,请重试')));
|
||||||
_PetGreetingCard(
|
});
|
||||||
greeting: greeting,
|
}
|
||||||
petName: widget.appState.pet.name,
|
// ready 后(含翻页追加)补一次可见性扫描:无滚动也能记首屏曝光。
|
||||||
petAvatar: widget.appState.pet.avatarUrl,
|
if (_viewSegment != null && _feed.phase == FeedPhase.ready) {
|
||||||
advice: widget.appState.locationWeather.petAdvice,
|
_scheduleVisibilityScan();
|
||||||
),
|
}
|
||||||
const SizedBox(height: 14),
|
return NotificationListener<ScrollNotification>(
|
||||||
TextField(
|
onNotification: _onScrollNotification,
|
||||||
onChanged: (value) => setState(() => query = value),
|
child: RefreshIndicator(
|
||||||
decoration: const InputDecoration(
|
onRefresh: () => segment == HomeSegment.feed
|
||||||
hintText: '搜索动态、医院、美容或遛狗服务…',
|
? _refreshFeed(userInitiated: true)
|
||||||
prefixIcon: Icon(Icons.search),
|
: Future<void>.delayed(const Duration(milliseconds: 500)),
|
||||||
),
|
child: ListView(
|
||||||
),
|
key: _listKey,
|
||||||
const SizedBox(height: 14),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||||
SegmentedButton<HomeSegment>(
|
children: [
|
||||||
segments: const [
|
_WeatherStatusBar(
|
||||||
ButtonSegment(
|
weather: widget.appState.locationWeather,
|
||||||
value: HomeSegment.feed,
|
onAreaTap: selectArea,
|
||||||
icon: Icon(Icons.forum_outlined),
|
onWeatherTap: showWeatherDetails,
|
||||||
label: Text('社区动态'),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
ButtonSegment(
|
_PetGreetingCard(
|
||||||
value: HomeSegment.services,
|
greeting: greeting,
|
||||||
icon: Icon(Icons.storefront_outlined),
|
petName: widget.appState.pet.name,
|
||||||
label: Text('本地服务'),
|
petAvatar: widget.appState.pet.avatarUrl,
|
||||||
),
|
advice: widget.appState.locationWeather.petAdvice,
|
||||||
],
|
),
|
||||||
selected: {segment},
|
const SizedBox(height: 14),
|
||||||
showSelectedIcon: false,
|
TextField(
|
||||||
onSelectionChanged: (value) {
|
onChanged: (value) => setState(() => query = value),
|
||||||
setState(() => segment = value.first);
|
decoration: const InputDecoration(
|
||||||
},
|
hintText: '搜索动态、医院、美容或遛狗服务…',
|
||||||
),
|
prefixIcon: Icon(Icons.search),
|
||||||
const SizedBox(height: 18),
|
),
|
||||||
if (segment == HomeSegment.feed) ...[
|
),
|
||||||
_StoryRow(onCreate: widget.onOpenCreate),
|
const SizedBox(height: 14),
|
||||||
const SizedBox(height: 18),
|
SegmentedButton<HomeSegment>(
|
||||||
_PromoCard(onTap: () => widget.onOpenServices(true)),
|
segments: const [
|
||||||
const SizedBox(height: 18),
|
ButtonSegment(
|
||||||
if (filteredPosts.isEmpty)
|
value: HomeSegment.feed,
|
||||||
const EmptyState(message: '没有找到相关动态')
|
icon: Icon(Icons.forum_outlined),
|
||||||
else
|
label: Text('社区动态'),
|
||||||
...filteredPosts.map(
|
),
|
||||||
(post) => Padding(
|
ButtonSegment(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
value: HomeSegment.services,
|
||||||
child: _PostCard(
|
icon: Icon(Icons.storefront_outlined),
|
||||||
post: post,
|
label: Text('本地服务'),
|
||||||
onTap: () => widget.onOpenPost(post),
|
),
|
||||||
onLike: () => widget.appState.updatePost(
|
],
|
||||||
post.copyWith(
|
selected: {segment},
|
||||||
hasLiked: !post.hasLiked,
|
showSelectedIcon: false,
|
||||||
likes: post.hasLiked ? post.likes - 1 : post.likes + 1,
|
onSelectionChanged: (value) => _onSegmentChanged(value.first),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
if (segment == HomeSegment.feed) ...[
|
||||||
|
_StoryRow(onCreate: widget.onOpenCompose),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_PromoCard(onTap: () => widget.onOpenServices(true)),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
..._feedSection(),
|
||||||
|
] else ...[
|
||||||
|
_CategoryGrid(onTap: (_) => widget.onOpenServices(false)),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'附近推荐',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
for (final item in ['综合', '距离', '评分'])
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 6),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text(item),
|
||||||
|
selected: sort == item,
|
||||||
|
onSelected: (_) => setState(() => sort = item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (filteredProviders.isEmpty)
|
||||||
|
const EmptyState(message: '没有找到相关服务')
|
||||||
|
else
|
||||||
|
...filteredProviders.map(
|
||||||
|
(provider) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _ProviderCompactCard(
|
||||||
|
provider: provider,
|
||||||
|
onTap: () => widget.onOpenServices(
|
||||||
|
provider.kind == ProviderKind.personal,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
_CategoryGrid(onTap: (_) => widget.onOpenServices(false)),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text('附近推荐', style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
const Spacer(),
|
|
||||||
for (final item in ['综合', '距离', '评分'])
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 6),
|
|
||||||
child: ChoiceChip(
|
|
||||||
label: Text(item),
|
|
||||||
selected: sort == item,
|
|
||||||
onSelected: (_) => setState(() => sort = item),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
if (filteredProviders.isEmpty)
|
);
|
||||||
const EmptyState(message: '没有找到相关服务')
|
},
|
||||||
else
|
);
|
||||||
...filteredProviders.map(
|
}
|
||||||
(provider) => Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
// ---- Feed 段四态渲染 ----
|
||||||
child: _ProviderCompactCard(
|
|
||||||
provider: provider,
|
List<Widget> _feedSection() {
|
||||||
onTap: () => widget.onOpenServices(
|
switch (_feed.phase) {
|
||||||
provider.kind == ProviderKind.personal,
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,7 +966,10 @@ class _PromoCard extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.brandGradient,
|
// 深端在左承载白字(4.5:1 达标);brandGradient 不承载正文文字(FIX-1)。
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [AppColors.primaryStrong, AppColors.primary],
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(26),
|
borderRadius: BorderRadius.circular(26),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -658,103 +1008,6 @@ class _PromoCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PostCard extends StatelessWidget {
|
|
||||||
const _PostCard({
|
|
||||||
required this.post,
|
|
||||||
required this.onTap,
|
|
||||||
required this.onLike,
|
|
||||||
});
|
|
||||||
|
|
||||||
final PostModel post;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
final VoidCallback onLike;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
RemoteImage(
|
|
||||||
url: post.authorAvatar,
|
|
||||||
width: 38,
|
|
||||||
height: 38,
|
|
||||||
borderRadius: BorderRadius.circular(19),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
post.authorName,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${post.time} · ${post.breedTag}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(Icons.more_horiz, color: AppColors.muted),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AspectRatio(
|
|
||||||
aspectRatio: 4 / 3,
|
|
||||||
child: RemoteImage(url: post.mainImage),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
post.content,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
ActionChip(
|
|
||||||
avatar: Icon(
|
|
||||||
post.hasLiked
|
|
||||||
? Icons.favorite
|
|
||||||
: Icons.favorite_border,
|
|
||||||
size: 17,
|
|
||||||
color: post.hasLiked ? Colors.red : AppColors.primary,
|
|
||||||
),
|
|
||||||
label: Text('${post.likes}'),
|
|
||||||
onPressed: onLike,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Chip(
|
|
||||||
avatar: const Icon(Icons.chat_bubble_outline, size: 16),
|
|
||||||
label: Text('${post.comments.length}'),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
const Icon(Icons.share_outlined, color: AppColors.muted),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CategoryGrid extends StatelessWidget {
|
class _CategoryGrid extends StatelessWidget {
|
||||||
const _CategoryGrid({required this.onTap});
|
const _CategoryGrid({required this.onTap});
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,79 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/media_uploader.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/post_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/post_compose_page.dart';
|
||||||
import 'package:patbond_flutter/features/create/create_page.dart';
|
import 'package:patbond_flutter/features/create/create_page.dart';
|
||||||
import 'package:patbond_flutter/features/home/home_page.dart';
|
import 'package:patbond_flutter/features/home/home_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_controller.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pets_page.dart';
|
import 'package:patbond_flutter/features/pets/pets_page.dart';
|
||||||
import 'package:patbond_flutter/features/post/post_detail_page.dart';
|
import 'package:patbond_flutter/features/post/post_detail_page.dart';
|
||||||
import 'package:patbond_flutter/features/profile/profile_page.dart';
|
import 'package:patbond_flutter/features/profile/profile_page.dart';
|
||||||
import 'package:patbond_flutter/features/services/services_page.dart';
|
import 'package:patbond_flutter/features/services/services_page.dart';
|
||||||
import 'package:patbond_flutter/models/models.dart';
|
|
||||||
import 'package:patbond_flutter/state/app_state.dart';
|
import 'package:patbond_flutter/state/app_state.dart';
|
||||||
import 'package:patbond_flutter/widgets/common.dart';
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
class MainShellPage extends StatefulWidget {
|
class MainShellPage extends StatefulWidget {
|
||||||
const MainShellPage({required this.appState, super.key});
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
final AppState appState;
|
final AppState appState;
|
||||||
|
|
||||||
|
/// 宠物档案状态(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;
|
||||||
|
|
||||||
|
/// 退出登录:调 logout 接口并清会话,认证状态机自动回登录页。
|
||||||
|
final Future<void> Function()? onLogout;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MainShellPage> createState() => _MainShellPageState();
|
State<MainShellPage> createState() => _MainShellPageState();
|
||||||
}
|
}
|
||||||
@@ -25,8 +84,26 @@ class _MainShellPageState extends State<MainShellPage> {
|
|||||||
|
|
||||||
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
|
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
|
||||||
|
|
||||||
|
/// Tab 索引 → pageName 枚举(03 号评估 §3.2 映射表;档案 Tab 自 T2-12
|
||||||
|
/// 起为真实宠物列表,页名由 pet_archive 改报 pet_list,06 §5.2 字典 v2)。
|
||||||
|
static const _tabPages = [
|
||||||
|
AnalyticsPageName.home,
|
||||||
|
AnalyticsPageName.create,
|
||||||
|
AnalyticsPageName.petList,
|
||||||
|
AnalyticsPageName.services,
|
||||||
|
AnalyticsPageName.profile,
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// 初始 Tab 曝光补一次(03 号评估 §3.2 手动补点 1)。
|
||||||
|
widget.pageViewTracker?.reportTab(_tabPages[currentIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
void selectTab(int index) {
|
void selectTab(int index) {
|
||||||
setState(() => currentIndex = index);
|
setState(() => currentIndex = index);
|
||||||
|
widget.pageViewTracker?.reportTab(_tabPages[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
void openServices(bool personal) {
|
void openServices(bool personal) {
|
||||||
@@ -34,17 +111,47 @@ class _MainShellPageState extends State<MainShellPage> {
|
|||||||
showPersonalServices = personal;
|
showPersonalServices = personal;
|
||||||
currentIndex = 3;
|
currentIndex = 3;
|
||||||
});
|
});
|
||||||
|
widget.pageViewTracker?.reportTab(_tabPages[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
void openPost(PostModel post) {
|
/// 帖子详情(T3-15:真实数据整页;Feed 卡片与详情共享
|
||||||
|
/// communityController,互动状态跨页一致)。
|
||||||
|
void openPost(String postId) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (context) =>
|
settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName),
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
@@ -59,25 +166,28 @@ class _MainShellPageState extends State<MainShellPage> {
|
|||||||
final pages = [
|
final pages = [
|
||||||
HomePage(
|
HomePage(
|
||||||
appState: widget.appState,
|
appState: widget.appState,
|
||||||
onOpenPost: openPost,
|
communityController: widget.communityController,
|
||||||
|
feedAnalytics: widget.feedAnalytics,
|
||||||
|
isActive: currentIndex == 0,
|
||||||
onOpenServices: openServices,
|
onOpenServices: openServices,
|
||||||
onOpenCreate: () => selectTab(1),
|
onOpenCompose: () => openCompose(PostEntryPoint.feed),
|
||||||
|
onOpenPost: openPost,
|
||||||
),
|
),
|
||||||
CreatePage(
|
CreatePage(
|
||||||
appState: widget.appState,
|
appState: widget.appState,
|
||||||
onPublished: (post) {
|
// T3-17:发布半边已真实化(发布页 push),AI 生成模拟原样留 M4。
|
||||||
selectTab(0);
|
onOpenCompose: () => openCompose(PostEntryPoint.createTab),
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
),
|
||||||
(_) => openPost(post),
|
PetsPage(
|
||||||
);
|
controller: widget.petsController,
|
||||||
},
|
analytics: widget.petAnalytics,
|
||||||
|
healthAnalytics: widget.healthRecordAnalytics,
|
||||||
),
|
),
|
||||||
PetsPage(appState: widget.appState),
|
|
||||||
ServicesPage(
|
ServicesPage(
|
||||||
showPersonal: showPersonalServices,
|
showPersonal: showPersonalServices,
|
||||||
locationWeather: widget.appState.locationWeather,
|
locationWeather: widget.appState.locationWeather,
|
||||||
),
|
),
|
||||||
ProfilePage(appState: widget.appState),
|
ProfilePage(appState: widget.appState, onLogout: widget.onLogout),
|
||||||
];
|
];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
/// 照护提醒创建表单页(T2-14)。
|
||||||
|
///
|
||||||
|
/// - 四类提醒类型(驱虫/体检/用药/其他)必选;标题必填;到期日期必选
|
||||||
|
/// (今日取此刻、未来日期取当日 12:00,转 UTC——与记录表单同一约定)。
|
||||||
|
/// - 契约:创建恒为 pending(不收 status);title/dueAt 无编辑端点,
|
||||||
|
/// 改期路径为忽略后重建。
|
||||||
|
/// - 埋点:health_record_create_started/succeeded/failed
|
||||||
|
/// (recordType=reminder)。
|
||||||
|
class CareReminderFormPage extends StatefulWidget {
|
||||||
|
const CareReminderFormPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.entryPoint = HealthRecordEntryPoint.recordList,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
final HealthRecordEntryPoint entryPoint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CareReminderFormPage> createState() => _CareReminderFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CareReminderFormPageState extends State<CareReminderFormPage> {
|
||||||
|
final _titleCtrl = TextEditingController();
|
||||||
|
|
||||||
|
CareReminderType? _type;
|
||||||
|
DateTime? _dueDate;
|
||||||
|
String? _typeError;
|
||||||
|
String? _titleError;
|
||||||
|
String? _dueError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
bool _startedFired = false;
|
||||||
|
int _attemptSeq = 0;
|
||||||
|
late final DateTime _openedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_openedAt = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markStarted() {
|
||||||
|
if (_startedFired) return;
|
||||||
|
_startedFired = true;
|
||||||
|
widget.analytics?.createStarted(
|
||||||
|
recordType: HealthRecordType.reminder,
|
||||||
|
entryPoint: widget.entryPoint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.createFailed(
|
||||||
|
recordType: HealthRecordType.reminder,
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: _attemptSeq,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 到期时刻:今日取此刻,其余日期取当日 12:00(本地),提交前转 UTC。
|
||||||
|
DateTime _dueAt() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final date = _dueDate!;
|
||||||
|
final sameDay =
|
||||||
|
date.year == now.year && date.month == now.month && date.day == now.day;
|
||||||
|
final local = sameDay ? now : DateTime(date.year, date.month, date.day, 12);
|
||||||
|
return local.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
_attemptSeq++;
|
||||||
|
final typeError = _type == null ? '请选择提醒类型' : null;
|
||||||
|
final titleError = _titleCtrl.text.trim().isEmpty ? '请输入提醒内容' : null;
|
||||||
|
final dueError = _dueDate == null ? '请选择到期日期' : null;
|
||||||
|
if (typeError != null || titleError != null || dueError != null) {
|
||||||
|
setState(() {
|
||||||
|
_typeError = typeError;
|
||||||
|
_titleError = titleError;
|
||||||
|
_dueError = dueError;
|
||||||
|
});
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final reminder = await widget.repository.createCareReminder(
|
||||||
|
widget.petId,
|
||||||
|
CreateCareReminderRequest(
|
||||||
|
reminderType: _type!,
|
||||||
|
title: _titleCtrl.text.trim(),
|
||||||
|
dueAt: _dueAt(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.analytics?.createSucceeded(
|
||||||
|
recordType: HealthRecordType.reminder,
|
||||||
|
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(reminder);
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('你没有权限为该宠物添加提醒');
|
||||||
|
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||||
|
} on PetNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
|
||||||
|
_trackFailed(HealthRecordFailureReason.notFound, 40401);
|
||||||
|
navigator.pop();
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('操作过于频繁,请稍后再试');
|
||||||
|
_trackFailed(HealthRecordFailureReason.rateLimited);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final isParam = error.code == ApiCodes.paramError;
|
||||||
|
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
|
||||||
|
_trackFailed(
|
||||||
|
isParam
|
||||||
|
? HealthRecordFailureReason.validationError
|
||||||
|
: HealthRecordFailureReason.serverError,
|
||||||
|
error.code,
|
||||||
|
);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_trackFailed(HealthRecordFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('添加照护提醒'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'提醒类型',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final type in CareReminderType.values)
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(careReminderTypeLabel(type)),
|
||||||
|
selected: _type == type,
|
||||||
|
onSelected: _submitting
|
||||||
|
? null
|
||||||
|
: (_) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_type = type;
|
||||||
|
_typeError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_typeError != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
_typeError!,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
AppTextField(
|
||||||
|
label: '提醒内容(如:体内外驱虫)',
|
||||||
|
controller: _titleCtrl,
|
||||||
|
prefixIcon: Icons.notifications_outlined,
|
||||||
|
errorText: _titleError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_titleError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_titleError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
),
|
||||||
|
tileColor: AppColors.surface,
|
||||||
|
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||||
|
title: const Text('到期日期', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
_dueDate == null ? '未选择' : dateToJson(_dueDate!),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.calendar_month_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
enabled: !_submitting,
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final value = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _dueDate ?? now,
|
||||||
|
firstDate: now,
|
||||||
|
lastDate: DateTime(now.year + 5),
|
||||||
|
);
|
||||||
|
if (value != null && mounted) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_dueDate = value;
|
||||||
|
_dueError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_dueError != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
_dueError!,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: '保存提醒',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
enum _ListPhase { loading, ready, error }
|
||||||
|
|
||||||
|
/// 照护提醒页(T2-14):契约不分页,`due_at ASC`(待办最先到期在前);
|
||||||
|
/// `?status=` 过滤走服务端白名单视图;逾期待办有显性视觉标识。
|
||||||
|
///
|
||||||
|
/// - 完成 / 忽略:`PATCH /care-reminders/{id}` 状态流转
|
||||||
|
/// (pending→completed 必带 completedAt,pending→dismissed 禁带);
|
||||||
|
/// 42202 规则兜底、40902 并发流转抢先提示后重拉。
|
||||||
|
/// - 提醒完成/忽略**不埋事件**(06 §7 缺口 3 既定取舍:完成率从
|
||||||
|
/// care_reminders 事实表出数;M3+ 推送实验时再增补)。
|
||||||
|
/// - 曝光埋点:每次进入首个成功加载上报一次
|
||||||
|
/// `health_record_viewed(recordType=reminder, source=pet_detail)`。
|
||||||
|
class CareRemindersPage extends StatefulWidget {
|
||||||
|
const CareRemindersPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
required this.canWrite,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
|
||||||
|
/// owner/caregiver 可写;viewer 隐藏创建与完成/忽略入口。
|
||||||
|
final bool canWrite;
|
||||||
|
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CareRemindersPage> createState() => _CareRemindersPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CareRemindersPageState extends State<CareRemindersPage> {
|
||||||
|
_ListPhase _phase = _ListPhase.loading;
|
||||||
|
List<CareReminder> _reminders = const [];
|
||||||
|
ApiException? _error;
|
||||||
|
CareReminderStatus? _filter;
|
||||||
|
bool _viewedFired = false;
|
||||||
|
bool _mutating = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_phase = _ListPhase.loading;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final reminders = await widget.repository.listCareReminders(
|
||||||
|
widget.petId,
|
||||||
|
status: _filter,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_reminders = reminders;
|
||||||
|
_phase = _ListPhase.ready;
|
||||||
|
});
|
||||||
|
if (!_viewedFired) {
|
||||||
|
_viewedFired = true;
|
||||||
|
widget.analytics?.viewed(
|
||||||
|
recordType: HealthRecordType.reminder,
|
||||||
|
source: HealthRecordViewSource.petDetail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error;
|
||||||
|
_phase = _ListPhase.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCreate() async {
|
||||||
|
final created = await Navigator.of(context).push<CareReminder>(
|
||||||
|
fadePageRoute(
|
||||||
|
CareReminderFormPage(
|
||||||
|
repository: widget.repository,
|
||||||
|
petId: widget.petId,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (created != null && mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已添加提醒')));
|
||||||
|
// 排序键 due_at ASC 在服务端,重新拉取而非本地猜位置。
|
||||||
|
await _load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完成时刻:今日取此刻,补记历史日期取当日 12:00(本地),转 UTC。
|
||||||
|
DateTime _completedAt(DateTime date) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final sameDay =
|
||||||
|
date.year == now.year && date.month == now.month && date.day == now.day;
|
||||||
|
final local = sameDay ? now : DateTime(date.year, date.month, date.day, 12);
|
||||||
|
return local.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _complete(CareReminder reminder) async {
|
||||||
|
final date = await showDialog<DateTime>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _CompleteDialog(title: reminder.title),
|
||||||
|
);
|
||||||
|
if (date == null || !mounted) return;
|
||||||
|
await _mutate(
|
||||||
|
reminder,
|
||||||
|
UpdateCareReminderRequest(
|
||||||
|
status: CareReminderStatus.completed,
|
||||||
|
// 契约:pending→completed 必带 completedAt(客户端提交,允许补记)。
|
||||||
|
completedAt: _completedAt(date),
|
||||||
|
),
|
||||||
|
successText: '已标记完成',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dismiss(CareReminder reminder) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('忽略这条提醒?'),
|
||||||
|
content: Text('「${reminder.title}」将不再出现在待办中,且不可恢复为待办。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('忽略'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
await _mutate(
|
||||||
|
reminder,
|
||||||
|
// 契约:pending→dismissed 禁带 completedAt。
|
||||||
|
const UpdateCareReminderRequest(status: CareReminderStatus.dismissed),
|
||||||
|
successText: '已忽略提醒',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _mutate(
|
||||||
|
CareReminder reminder,
|
||||||
|
UpdateCareReminderRequest request, {
|
||||||
|
required String successText,
|
||||||
|
}) async {
|
||||||
|
if (_mutating) return;
|
||||||
|
setState(() => _mutating = true);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
try {
|
||||||
|
await widget.repository.updateCareReminder(reminder.id, request);
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(successText)));
|
||||||
|
await _load();
|
||||||
|
} on CareReminderRuleException {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 42202:状态机/completedAt 一致性兜底(前端结构上已按状态发字段)。
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('提醒状态不满足流转规则,已刷新,请重试')),
|
||||||
|
);
|
||||||
|
await _load();
|
||||||
|
} on PetVersionConflictException {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 40902:并发流转抢先(条件更新守卫落空),处理方式与乐观锁一致。
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('提醒已在其他设备被处理,已刷新')));
|
||||||
|
await _load();
|
||||||
|
} on PetRecordNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('提醒不存在或已被删除,已刷新')));
|
||||||
|
await _load();
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该提醒')));
|
||||||
|
} on ApiBusinessException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _mutating = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('照护提醒'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (widget.canWrite && _phase == _ListPhase.ready)
|
||||||
|
IconButton(
|
||||||
|
tooltip: '添加提醒',
|
||||||
|
onPressed: _openCreate,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||||
|
child: _filterChips(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: switch (_phase) {
|
||||||
|
_ListPhase.loading => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
_ListPhase.error => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
InlineErrorBanner(message: petLoadErrorMessage(_error)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(onPressed: _load, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready when _reminders.isEmpty => Center(
|
||||||
|
child: SingleChildScrollView(child: _emptyState()),
|
||||||
|
),
|
||||||
|
_ListPhase.ready => _list(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 状态过滤(服务端白名单视图;「待办」即 ?status=pending 按 due_at 查询)。
|
||||||
|
Widget _filterChips() {
|
||||||
|
final options = <(String, CareReminderStatus?)>[
|
||||||
|
('全部', null),
|
||||||
|
('待办', CareReminderStatus.pending),
|
||||||
|
('已完成', CareReminderStatus.completed),
|
||||||
|
('已忽略', CareReminderStatus.dismissed),
|
||||||
|
];
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final (label, status) in options)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: _filter == status,
|
||||||
|
onSelected: (_) {
|
||||||
|
if (_filter == status) return;
|
||||||
|
setState(() => _filter = status);
|
||||||
|
_load();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _emptyState() {
|
||||||
|
// 过滤视图下的空态不给 CTA(05 §4.2 筛选后空态惯例)。
|
||||||
|
if (_filter != null) {
|
||||||
|
return EmptyStateIllustration(
|
||||||
|
icon: Icons.notifications_none_outlined,
|
||||||
|
title: '暂无「${careReminderStatusLabel(_filter!)}」提醒',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return EmptyStateIllustration(
|
||||||
|
icon: Icons.notifications_none_outlined,
|
||||||
|
title: '还没有照护提醒',
|
||||||
|
description: '驱虫、体检、用药……到点不忘每一件照护小事',
|
||||||
|
ctaLabel: widget.canWrite ? '添加第一条' : null,
|
||||||
|
onCtaPressed: widget.canWrite ? _openCreate : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _list() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
|
||||||
|
children: [
|
||||||
|
for (final reminder in _reminders) ...[
|
||||||
|
_ReminderTile(
|
||||||
|
reminder: reminder,
|
||||||
|
now: now,
|
||||||
|
// 完成/忽略仅对待办可用(终态不可迁;viewer 无写权限)。
|
||||||
|
onComplete:
|
||||||
|
widget.canWrite &&
|
||||||
|
reminder.status == CareReminderStatus.pending &&
|
||||||
|
!_mutating
|
||||||
|
? () => _complete(reminder)
|
||||||
|
: null,
|
||||||
|
onDismiss:
|
||||||
|
widget.canWrite &&
|
||||||
|
reminder.status == CareReminderStatus.pending &&
|
||||||
|
!_mutating
|
||||||
|
? () => _dismiss(reminder)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提醒条目:RecordTypeDot + 标题 + 时间副行 + 状态 TagPill(逾期红标),
|
||||||
|
/// 待办行附「标记完成 / 忽略」动作(图标 + 文字双通道)。
|
||||||
|
class _ReminderTile extends StatelessWidget {
|
||||||
|
const _ReminderTile({
|
||||||
|
required this.reminder,
|
||||||
|
required this.now,
|
||||||
|
this.onComplete,
|
||||||
|
this.onDismiss,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CareReminder reminder;
|
||||||
|
final DateTime now;
|
||||||
|
final VoidCallback? onComplete;
|
||||||
|
final VoidCallback? onDismiss;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final overdue = isReminderOverdue(reminder, now);
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
RecordTypeDot(
|
||||||
|
type: recordTypeForReminder(reminder.reminderType),
|
||||||
|
size: RecordTypeDotSize.md,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
reminder.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${careReminderTypeLabel(reminder.reminderType)} · '
|
||||||
|
'${reminderDateLine(reminder)}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
// 逾期副行同步警示色(与标签双位标识)。
|
||||||
|
color: overdue
|
||||||
|
? AppColors.errorDark
|
||||||
|
: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: overdue
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TagPill(
|
||||||
|
reminderStatusTag(reminder, now),
|
||||||
|
color: reminderStatusColor(reminder, now),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (onComplete != null || onDismiss != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onDismiss,
|
||||||
|
icon: const Icon(Icons.close, size: 16),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
label: const Text('忽略'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onComplete,
|
||||||
|
icon: const Icon(Icons.check_circle_outline, size: 16),
|
||||||
|
label: const Text('标记完成'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记完成对话框:完成日期默认今天,可补记历史时刻(契约 completedAt
|
||||||
|
/// 由客户端提交)。确认返回所选日期。
|
||||||
|
class _CompleteDialog extends StatefulWidget {
|
||||||
|
const _CompleteDialog({required this.title});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CompleteDialog> createState() => _CompleteDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompleteDialogState extends State<_CompleteDialog> {
|
||||||
|
DateTime _date = DateTime.now();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('标记完成'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'「${widget.title}」',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||||
|
title: const Text('完成日期', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
dateToJson(_date),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final value = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _date,
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: now,
|
||||||
|
);
|
||||||
|
if (value != null && mounted) {
|
||||||
|
setState(() => _date = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(_date),
|
||||||
|
child: const Text('确认完成'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/money.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
/// 健康事件编辑页(T2-14):顶层短路径 `PATCH /health-events/{id}`。
|
||||||
|
///
|
||||||
|
/// - 契约:仅 title / notes / amountCents 可编辑;eventType / occurredAt
|
||||||
|
/// 为条目身份,静态展示不可改;不支持清空回 null(清空输入视为不变更)。
|
||||||
|
/// - 差量提交:只发送改动字段 + version;无变更不发 PATCH 直接返回。
|
||||||
|
/// - 40902 版本冲突照 T2-12 模式:明确提示 + 自动取最新版本更新乐观锁
|
||||||
|
/// 基线(保留用户输入),用户核对后重新保存。契约无按 id 读取端点,
|
||||||
|
/// 最新版本经时间线分页检索取回。
|
||||||
|
/// - 埋点:health_record_edit_succeeded(fieldCount) / edit_failed
|
||||||
|
/// (failureReason 含 conflict,recordType=health_event)。
|
||||||
|
class HealthEventEditPage extends StatefulWidget {
|
||||||
|
const HealthEventEditPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.event,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
|
||||||
|
/// 编辑基线(含 version 乐观锁与差量比较基准)。
|
||||||
|
final HealthEvent event;
|
||||||
|
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HealthEventEditPage> createState() => _HealthEventEditPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HealthEventEditPageState extends State<HealthEventEditPage> {
|
||||||
|
late final TextEditingController _titleCtrl;
|
||||||
|
late final TextEditingController _amountCtrl;
|
||||||
|
late final TextEditingController _notesCtrl;
|
||||||
|
|
||||||
|
/// 编辑基线:40902 冲突刷新后更新(version 与差量计算的比较基准)。
|
||||||
|
late HealthEvent _base;
|
||||||
|
|
||||||
|
String? _titleError;
|
||||||
|
String? _amountError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_base = widget.event;
|
||||||
|
_titleCtrl = TextEditingController(text: _base.title);
|
||||||
|
_amountCtrl = TextEditingController(
|
||||||
|
text: _base.amountCents == null
|
||||||
|
? ''
|
||||||
|
: formatCentsAsYuan(_base.amountCents!),
|
||||||
|
);
|
||||||
|
_notesCtrl = TextEditingController(text: _base.notes ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleCtrl.dispose();
|
||||||
|
_amountCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.editFailed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
reason: reason,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 差量请求:只含改动字段(契约不支持清空回 null——清空输入视为不变更)。
|
||||||
|
/// 无实际变更返回 null。
|
||||||
|
UpdateHealthEventRequest? _buildDiff() {
|
||||||
|
final title = _titleCtrl.text.trim();
|
||||||
|
final notes = _notesCtrl.text.trim();
|
||||||
|
final amountText = _amountCtrl.text.trim();
|
||||||
|
final amountCents = amountText.isEmpty
|
||||||
|
? null
|
||||||
|
: parseYuanToCents(amountText);
|
||||||
|
final request = UpdateHealthEventRequest(
|
||||||
|
version: _base.version,
|
||||||
|
title: title != _base.title ? title : null,
|
||||||
|
notes: notes.isNotEmpty && notes != (_base.notes ?? '') ? notes : null,
|
||||||
|
amountCents: amountCents != null && amountCents != _base.amountCents
|
||||||
|
? amountCents
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
// 只剩 version 一个键 → 无实际变更。
|
||||||
|
return request.toJson().length == 1 ? null : request;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
final titleError = _titleCtrl.text.trim().isEmpty ? '标题不能为空' : null;
|
||||||
|
final amountText = _amountCtrl.text.trim();
|
||||||
|
final amountError =
|
||||||
|
amountText.isNotEmpty && parseYuanToCents(amountText) == null
|
||||||
|
? '金额格式不正确,最多两位小数'
|
||||||
|
: null;
|
||||||
|
if (titleError != null || amountError != null) {
|
||||||
|
setState(() {
|
||||||
|
_titleError = titleError;
|
||||||
|
_amountError = amountError;
|
||||||
|
});
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final request = _buildDiff();
|
||||||
|
if (request == null) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final updated = await widget.repository.updateHealthEvent(
|
||||||
|
_base.id,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
widget.analytics?.editSucceeded(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
fieldCount: request.toJson().length - 1,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(updated);
|
||||||
|
} on PetVersionConflictException {
|
||||||
|
_trackFailed(HealthRecordFailureReason.conflict, 40902);
|
||||||
|
await _handleVersionConflict();
|
||||||
|
} on PetRecordNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除')));
|
||||||
|
_trackFailed(HealthRecordFailureReason.notFound, 40402);
|
||||||
|
navigator.pop();
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('你没有权限修改该记录');
|
||||||
|
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('操作过于频繁,请稍后再试');
|
||||||
|
_trackFailed(HealthRecordFailureReason.rateLimited);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final isParam = error.code == ApiCodes.paramError;
|
||||||
|
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
|
||||||
|
_trackFailed(
|
||||||
|
isParam
|
||||||
|
? HealthRecordFailureReason.validationError
|
||||||
|
: HealthRecordFailureReason.serverError,
|
||||||
|
error.code,
|
||||||
|
);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_trackFailed(HealthRecordFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40902:提示 + 刷新路径——契约无按 id 读取端点,经时间线分页检索
|
||||||
|
/// 取回最新版本更新乐观锁基线(保留用户输入),用户核对后重新保存。
|
||||||
|
Future<void> _handleVersionConflict() async {
|
||||||
|
try {
|
||||||
|
final fresh = await _fetchLatest();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (fresh == null) {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除')));
|
||||||
|
navigator.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _base = fresh);
|
||||||
|
_showFormError('记录已在其他设备被修改,已获取最新版本,请核对后重新保存');
|
||||||
|
} on ApiException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('记录已在其他设备被修改,请返回后刷新重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 occurred_at DESC 分页检索本记录(上限 10 页防御性截断)。
|
||||||
|
Future<HealthEvent?> _fetchLatest() async {
|
||||||
|
String? cursor;
|
||||||
|
for (var page = 0; page < 10; page++) {
|
||||||
|
final result = await widget.repository.listHealthEvents(
|
||||||
|
_base.petId,
|
||||||
|
limit: 50,
|
||||||
|
cursor: cursor,
|
||||||
|
);
|
||||||
|
for (final item in result.items) {
|
||||||
|
if (item.id == _base.id) return item;
|
||||||
|
}
|
||||||
|
if (!result.hasMore) return null;
|
||||||
|
cursor = result.nextCursor;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final style = recordTypeStyles[recordTypeForHealthEvent(_base.eventType)]!;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('编辑健康事件'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
||||||
|
children: [
|
||||||
|
// 事件身份静态区:类型与发生时间不可编辑(契约不在请求体)。
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
RecordTypeDot(
|
||||||
|
type: recordTypeForHealthEvent(_base.eventType),
|
||||||
|
size: RecordTypeDotSize.md,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
healthEventTypeLabel(_base.eventType),
|
||||||
|
style: TextStyle(
|
||||||
|
color: style.inkColor,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
formatOccurredAt(_base.occurredAt),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
AppTextField(
|
||||||
|
label: '标题',
|
||||||
|
controller: _titleCtrl,
|
||||||
|
prefixIcon: Icons.title_outlined,
|
||||||
|
errorText: _titleError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_titleError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_titleError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '金额(元)',
|
||||||
|
controller: _amountCtrl,
|
||||||
|
prefixIcon: Icons.payments_outlined,
|
||||||
|
errorText: _amountError,
|
||||||
|
helperText: '以元填写,最多两位小数;清空视为不变更',
|
||||||
|
enabled: !_submitting,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_amountError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_amountError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '备注',
|
||||||
|
controller: _notesCtrl,
|
||||||
|
prefixIcon: Icons.sticky_note_2_outlined,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: '保存修改',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/money.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
/// 健康事件录入表单页(T2-14)。
|
||||||
|
///
|
||||||
|
/// - 六类事件类型选择(05 §4.4 类型选择器形态:RecordTypeDot sm + 标签,
|
||||||
|
/// 图标 + 文字双通道);类型必选。
|
||||||
|
/// - 发生时间默认「现在」,可回选日期(非今日取当日 12:00,与体重表单
|
||||||
|
/// 同一约定);提交前转 UTC,ISO 8601 带 Z 上送。
|
||||||
|
/// - 金额可选:**UI 以元录入/展示,传输为整数分**(money.dart 换算,
|
||||||
|
/// 最多两位小数,非法拦截);后端提交小数 400/40000 兜底。
|
||||||
|
/// - 埋点:health_record_create_started/succeeded/failed
|
||||||
|
/// (recordType=health_event)。
|
||||||
|
class HealthEventFormPage extends StatefulWidget {
|
||||||
|
const HealthEventFormPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.entryPoint = HealthRecordEntryPoint.recordList,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
final HealthRecordEntryPoint entryPoint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HealthEventFormPage> createState() => _HealthEventFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HealthEventFormPageState extends State<HealthEventFormPage> {
|
||||||
|
final _titleCtrl = TextEditingController();
|
||||||
|
final _amountCtrl = TextEditingController();
|
||||||
|
final _notesCtrl = TextEditingController();
|
||||||
|
|
||||||
|
HealthEventType? _type;
|
||||||
|
DateTime _occurredDate = DateTime.now();
|
||||||
|
String? _typeError;
|
||||||
|
String? _titleError;
|
||||||
|
String? _amountError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
bool _startedFired = false;
|
||||||
|
int _attemptSeq = 0;
|
||||||
|
late final DateTime _openedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_openedAt = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleCtrl.dispose();
|
||||||
|
_amountCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markStarted() {
|
||||||
|
if (_startedFired) return;
|
||||||
|
_startedFired = true;
|
||||||
|
widget.analytics?.createStarted(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
entryPoint: widget.entryPoint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.createFailed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: _attemptSeq,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发生时刻:今日取此刻,历史日期取当日 12:00(本地),提交前转 UTC
|
||||||
|
/// (与体重表单同一约定,规避无时区后缀的解析歧义)。
|
||||||
|
DateTime _occurredAt() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final sameDay =
|
||||||
|
_occurredDate.year == now.year &&
|
||||||
|
_occurredDate.month == now.month &&
|
||||||
|
_occurredDate.day == now.day;
|
||||||
|
final local = sameDay
|
||||||
|
? now
|
||||||
|
: DateTime(
|
||||||
|
_occurredDate.year,
|
||||||
|
_occurredDate.month,
|
||||||
|
_occurredDate.day,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
return local.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
_attemptSeq++;
|
||||||
|
final typeError = _type == null ? '请选择事件类型' : null;
|
||||||
|
final titleError = _titleCtrl.text.trim().isEmpty ? '请输入标题' : null;
|
||||||
|
final amountText = _amountCtrl.text.trim();
|
||||||
|
final amountError =
|
||||||
|
amountText.isNotEmpty && parseYuanToCents(amountText) == null
|
||||||
|
? '金额格式不正确,最多两位小数'
|
||||||
|
: null;
|
||||||
|
if (typeError != null || titleError != null || amountError != null) {
|
||||||
|
setState(() {
|
||||||
|
_typeError = typeError;
|
||||||
|
_titleError = titleError;
|
||||||
|
_amountError = amountError;
|
||||||
|
});
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final notes = _notesCtrl.text.trim();
|
||||||
|
final record = await widget.repository.createHealthEvent(
|
||||||
|
widget.petId,
|
||||||
|
CreateHealthEventRequest(
|
||||||
|
eventType: _type!,
|
||||||
|
occurredAt: _occurredAt(),
|
||||||
|
title: _titleCtrl.text.trim(),
|
||||||
|
notes: notes.isEmpty ? null : notes,
|
||||||
|
// 元 → 整数分换算,DTO 层保持整数分(开发计划 §4.3)。
|
||||||
|
amountCents: amountText.isEmpty ? null : parseYuanToCents(amountText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.analytics?.createSucceeded(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(record);
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('你没有权限为该宠物添加记录');
|
||||||
|
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||||
|
} on PetNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
|
||||||
|
_trackFailed(HealthRecordFailureReason.notFound, 40401);
|
||||||
|
navigator.pop();
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('操作过于频繁,请稍后再试');
|
||||||
|
_trackFailed(HealthRecordFailureReason.rateLimited);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final isParam = error.code == ApiCodes.paramError;
|
||||||
|
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
|
||||||
|
_trackFailed(
|
||||||
|
isParam
|
||||||
|
? HealthRecordFailureReason.validationError
|
||||||
|
: HealthRecordFailureReason.serverError,
|
||||||
|
error.code,
|
||||||
|
);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_trackFailed(HealthRecordFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('记录健康事件'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'事件类型',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_typeSelector(),
|
||||||
|
if (_typeError != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
_typeError!,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
AppTextField(
|
||||||
|
label: '标题(如:皮肤检查)',
|
||||||
|
controller: _titleCtrl,
|
||||||
|
prefixIcon: Icons.title_outlined,
|
||||||
|
errorText: _titleError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_titleError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_titleError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
),
|
||||||
|
tileColor: AppColors.surface,
|
||||||
|
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||||
|
title: const Text('发生日期', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
dateToJson(_occurredDate),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.calendar_month_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
enabled: !_submitting,
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final value = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _occurredDate,
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: now,
|
||||||
|
);
|
||||||
|
if (value != null && mounted) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() => _occurredDate = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '金额(元,可选)',
|
||||||
|
controller: _amountCtrl,
|
||||||
|
prefixIcon: Icons.payments_outlined,
|
||||||
|
errorText: _amountError,
|
||||||
|
helperText: '以元填写,最多两位小数,如 128.50',
|
||||||
|
enabled: !_submitting,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_amountError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_amountError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '备注(可选)',
|
||||||
|
controller: _notesCtrl,
|
||||||
|
prefixIcon: Icons.sticky_note_2_outlined,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (_) => _markStarted(),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: '保存记录',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 六类类型选择器(05 §4.4:RecordTypeDot sm 上、标签下;
|
||||||
|
/// 选中 surfaceTint 底 + primaryDark 标签,未选中 inkSoft)。
|
||||||
|
Widget _typeSelector() {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final type in HealthEventType.values)
|
||||||
|
_TypeUnit(
|
||||||
|
type: type,
|
||||||
|
selected: _type == type,
|
||||||
|
enabled: !_submitting,
|
||||||
|
onTap: () {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_type = type;
|
||||||
|
_typeError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypeUnit extends StatelessWidget {
|
||||||
|
const _TypeUnit({
|
||||||
|
required this.type,
|
||||||
|
required this.selected,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HealthEventType type;
|
||||||
|
final bool selected;
|
||||||
|
final bool enabled;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: selected ? AppColors.surfaceTint : AppColors.surface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
color: selected ? AppColors.primaryStrong : AppColors.border,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 64, minHeight: 52),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
RecordTypeDot(
|
||||||
|
type: recordTypeForHealthEvent(type),
|
||||||
|
size: RecordTypeDotSize.sm,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
healthEventTypeLabel(type),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||||||
|
color: selected ? AppColors.primaryDark : AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_event_edit_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_event_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/money.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
enum _ListPhase { loading, ready, error }
|
||||||
|
|
||||||
|
/// 健康事件时间线页(T2-14):cursor 分页(occurred_at DESC,
|
||||||
|
/// 「加载更多」追加,末页收起),按月分组(05 §4.2 组头),四态齐备。
|
||||||
|
///
|
||||||
|
/// - 六类事件经 [recordTypeForHealthEvent] 映射 RecordTypeDot,
|
||||||
|
/// 类型标签 TagPill 双通道呈现;金额以元展示(传输为整数分)。
|
||||||
|
/// - 录入经 [HealthEventFormPage];成功后重拉首页(排序与月分组以
|
||||||
|
/// 服务端为准,不本地猜位置)。
|
||||||
|
/// - 编辑经 [HealthEventEditPage](canWrite 点条目进入);成功就地替换。
|
||||||
|
/// - 曝光埋点:每次进入首个成功加载上报一次
|
||||||
|
/// `health_record_viewed(recordType=health_event, source=pet_detail)`。
|
||||||
|
class HealthEventsPage extends StatefulWidget {
|
||||||
|
const HealthEventsPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
required this.canWrite,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
|
||||||
|
/// owner/caregiver 可写;viewer 隐藏录入/编辑入口(40300 语义前置)。
|
||||||
|
final bool canWrite;
|
||||||
|
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
|
||||||
|
/// 每页条数(测试注入小页验证分页;缺省走服务端默认 20)。
|
||||||
|
final int? pageSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HealthEventsPage> createState() => _HealthEventsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HealthEventsPageState extends State<HealthEventsPage> {
|
||||||
|
_ListPhase _phase = _ListPhase.loading;
|
||||||
|
List<HealthEvent> _events = const [];
|
||||||
|
String? _nextCursor;
|
||||||
|
bool _hasMore = false;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
ApiException? _error;
|
||||||
|
bool _viewedFired = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadFirstPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFirstPage() async {
|
||||||
|
setState(() {
|
||||||
|
_phase = _ListPhase.loading;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final page = await widget.repository.listHealthEvents(
|
||||||
|
widget.petId,
|
||||||
|
limit: widget.pageSize,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_events = page.items;
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_phase = _ListPhase.ready;
|
||||||
|
});
|
||||||
|
if (!_viewedFired) {
|
||||||
|
_viewedFired = true;
|
||||||
|
widget.analytics?.viewed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
source: HealthRecordViewSource.petDetail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error;
|
||||||
|
_phase = _ListPhase.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMore() async {
|
||||||
|
if (_loadingMore || !_hasMore) return;
|
||||||
|
setState(() => _loadingMore = true);
|
||||||
|
try {
|
||||||
|
final page = await widget.repository.listHealthEvents(
|
||||||
|
widget.petId,
|
||||||
|
limit: widget.pageSize,
|
||||||
|
cursor: _nextCursor,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_events = [..._events, ...page.items];
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
});
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loadingMore = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCreate() async {
|
||||||
|
final created = await Navigator.of(context).push<HealthEvent>(
|
||||||
|
fadePageRoute(
|
||||||
|
HealthEventFormPage(
|
||||||
|
repository: widget.repository,
|
||||||
|
petId: widget.petId,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
// record_form:健康记录漏斗到达段页名(06 §1.6 / §2.2)。
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (created != null && mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已记录健康事件')));
|
||||||
|
// occurred_at DESC + 月分组:补录历史日期的位置由服务端定,
|
||||||
|
// 重拉首页而非本地猜位置。
|
||||||
|
await _loadFirstPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEdit(HealthEvent event) async {
|
||||||
|
final updated = await Navigator.of(context).push<HealthEvent>(
|
||||||
|
// 编辑页不带路由名:record_form 专属创建漏斗到达段(T2-12 先例)。
|
||||||
|
fadePageRoute(
|
||||||
|
HealthEventEditPage(
|
||||||
|
repository: widget.repository,
|
||||||
|
event: event,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (updated != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
// occurredAt 不可编辑 → 排序/分组位置不变,就地替换安全。
|
||||||
|
_events = [
|
||||||
|
for (final item in _events)
|
||||||
|
if (item.id == updated.id) updated else item,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已保存修改')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('健康时间线'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (widget.canWrite && _phase == _ListPhase.ready)
|
||||||
|
IconButton(
|
||||||
|
tooltip: '记录健康事件',
|
||||||
|
onPressed: _openCreate,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: switch (_phase) {
|
||||||
|
_ListPhase.loading => const Center(child: CircularProgressIndicator()),
|
||||||
|
_ListPhase.error => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
InlineErrorBanner(message: petLoadErrorMessage(_error)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _loadFirstPage,
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready when _events.isEmpty => Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: EmptyStateIllustration(
|
||||||
|
icon: Icons.event_note_outlined,
|
||||||
|
title: '还没有健康记录',
|
||||||
|
description: '就医、驱虫、洗护……随手记下毛孩子的健康点滴',
|
||||||
|
ctaLabel: widget.canWrite ? '记录第一条' : null,
|
||||||
|
onCtaPressed: widget.canWrite ? _openCreate : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready => _list(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按月分组渲染:服务端 occurred_at DESC 保证同月相邻,
|
||||||
|
/// 月份变化处插组头(05 §4.2「2026 年 9 月」)。
|
||||||
|
Widget _list() {
|
||||||
|
final children = <Widget>[];
|
||||||
|
String? currentMonth;
|
||||||
|
for (final event in _events) {
|
||||||
|
final month = healthEventMonthHeader(event.occurredAt);
|
||||||
|
if (month != currentMonth) {
|
||||||
|
currentMonth = month;
|
||||||
|
children.add(
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: children.isEmpty ? 0 : 14, bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
month,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
children
|
||||||
|
..add(
|
||||||
|
_HealthEventTile(
|
||||||
|
event: event,
|
||||||
|
onTap: widget.canWrite ? () => _openEdit(event) : null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(const SizedBox(height: 10));
|
||||||
|
}
|
||||||
|
if (_hasMore) {
|
||||||
|
children.add(
|
||||||
|
Center(
|
||||||
|
child: _loadingMore
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: TextButton(onPressed: _loadMore, child: const Text('加载更多')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadFirstPage,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间线条目(05 §3.3 形态):RecordTypeDot(六类映射) + 标题 +
|
||||||
|
/// 日期/备注副行;尾部类型 TagPill(双通道)与金额(元展示,
|
||||||
|
/// 15/w800 类型文字色)。
|
||||||
|
class _HealthEventTile extends StatelessWidget {
|
||||||
|
const _HealthEventTile({required this.event, this.onTap});
|
||||||
|
|
||||||
|
final HealthEvent event;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final recordType = recordTypeForHealthEvent(event.eventType);
|
||||||
|
final style = recordTypeStyles[recordType]!;
|
||||||
|
final meta = StringBuffer(formatOccurredAt(event.occurredAt));
|
||||||
|
if (event.notes != null && event.notes!.isNotEmpty) {
|
||||||
|
meta.write(' · ${event.notes}');
|
||||||
|
}
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
RecordTypeDot(type: recordType, size: RecordTypeDotSize.md),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
event.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
meta.toString(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TagPill(
|
||||||
|
healthEventTypeLabel(event.eventType),
|
||||||
|
color: style.baseColor,
|
||||||
|
),
|
||||||
|
if (event.amountCents != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'¥${formatCentsAsYuan(event.amountCents!)}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: style.inkColor,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
|
||||||
|
/// health_record 域埋点强类型封装(06 号规划 §1.4 字典 v2;后端白名单
|
||||||
|
/// 已扩充就绪,24 号报告 §2.2)。沿用 13 号规范 §3.1 惯例:枚举编译期
|
||||||
|
/// 锁死,业务代码禁止手拼事件名与属性。
|
||||||
|
///
|
||||||
|
/// T2-13 挂接创建漏斗三事件 + viewed;T2-14 补挂 edit_succeeded/failed
|
||||||
|
/// (健康事件编辑、疫苗标记完成/取消)。`health_record_deleted` 因 M2
|
||||||
|
/// 契约无删除端点暂无挂接点,留待删除交互落地。
|
||||||
|
|
||||||
|
/// 记录类型(06 §1.4 recordType 枚举,四类记录接口对应)。
|
||||||
|
enum HealthRecordType {
|
||||||
|
weight('weight'),
|
||||||
|
vaccine('vaccine'),
|
||||||
|
healthEvent('health_event'),
|
||||||
|
reminder('reminder');
|
||||||
|
|
||||||
|
const HealthRecordType(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录创建入口(06 §1.4 entryPoint 枚举)。
|
||||||
|
enum HealthRecordEntryPoint {
|
||||||
|
petDetail('pet_detail'),
|
||||||
|
recordList('record_list'),
|
||||||
|
reminder('reminder');
|
||||||
|
|
||||||
|
const HealthRecordEntryPoint(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建/编辑失败原因(06 §1.4 基底 + M2 验收新增三值;`conflict`
|
||||||
|
/// 仅编辑链路会出现——40902 乐观锁/条件更新守卫落空)。与 pet 域同款
|
||||||
|
/// 网络归并口径:断网/超时/5xx 均并入 network_error,server_error
|
||||||
|
/// 保留给无法归类的兜底。
|
||||||
|
enum HealthRecordFailureReason {
|
||||||
|
validationError('validation_error'),
|
||||||
|
permissionDenied('permission_denied'),
|
||||||
|
notFound('not_found'),
|
||||||
|
rateLimited('rate_limited'),
|
||||||
|
networkError('network_error'),
|
||||||
|
serverError('server_error'),
|
||||||
|
conflict('conflict');
|
||||||
|
|
||||||
|
const HealthRecordFailureReason(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// viewed 的来源(06 §1.4 source 枚举)。
|
||||||
|
enum HealthRecordViewSource {
|
||||||
|
recordList('record_list'),
|
||||||
|
petDetail('pet_detail'),
|
||||||
|
reminder('reminder');
|
||||||
|
|
||||||
|
const HealthRecordViewSource(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HealthRecordAnalytics {
|
||||||
|
HealthRecordAnalytics(this._track);
|
||||||
|
|
||||||
|
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
|
||||||
|
final TrackEventFn _track;
|
||||||
|
|
||||||
|
/// 进入某类记录的创建表单并产生首次输入(每次进入记一次,表单层去重)。
|
||||||
|
void createStarted({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required HealthRecordEntryPoint entryPoint,
|
||||||
|
}) {
|
||||||
|
_track('health_record_create_started', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'entryPoint': entryPoint.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建接口成功响应(漏斗事件,北极星与 H1/H3/H4 的核心数据源)。
|
||||||
|
///
|
||||||
|
/// [photoCount] 无照片为 0(M2 不做媒体上传,恒 0,字段随字典保留)。
|
||||||
|
void createSucceeded({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required int durationMs,
|
||||||
|
int photoCount = 0,
|
||||||
|
}) {
|
||||||
|
_track('health_record_create_succeeded', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'durationMs': durationMs,
|
||||||
|
'photoCount': photoCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建失败:失败响应 / 超时 / 本地校验拦截。
|
||||||
|
///
|
||||||
|
/// [errorCode] 为业务错误码(本地校验/网络错误时缺席);
|
||||||
|
/// [httpStatus] 由五位业务码推导(`code ~/ 100`);
|
||||||
|
/// [attemptSeq] 为本次表单会话内第几次提交尝试(从 1 起)。
|
||||||
|
void createFailed({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required HealthRecordFailureReason reason,
|
||||||
|
required int attemptSeq,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('health_record_create_failed', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'attemptSeq': attemptSeq,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录曝光。06 §1.4 定义在详情页可见;M2 体重/疫苗无独立详情页,
|
||||||
|
/// 以「列表页每次进入首个成功加载」为曝光时点(每次进入一次、
|
||||||
|
/// 不随滚动逐条上报,防事件洪水的意图不变)。
|
||||||
|
void viewed({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required HealthRecordViewSource source,
|
||||||
|
}) {
|
||||||
|
_track('health_record_viewed', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'source': source.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑保存成功响应(06 §1.4:编辑不设 started)。
|
||||||
|
///
|
||||||
|
/// [fieldCount] 为本次变更字段数(差量 PATCH 的键数,不含 version)。
|
||||||
|
void editSucceeded({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required int fieldCount,
|
||||||
|
}) {
|
||||||
|
_track('health_record_edit_succeeded', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'fieldCount': fieldCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑保存失败(失败原因含 `conflict`——40902 版本/状态守卫冲突,
|
||||||
|
/// M2 验收「并发冲突明确」场景的数据面)。属性集无 attemptSeq
|
||||||
|
/// (白名单对齐 06 §1.5)。
|
||||||
|
void editFailed({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required HealthRecordFailureReason reason,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('health_record_edit_failed', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/// 体重 / 疫苗 / 健康事件 / 提醒展示与输入解析的纯函数集合
|
||||||
|
/// (列表 / 表单共用,可单测)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
|
/// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。
|
||||||
|
/// 非法(非数字、越界、三位小数)返回 null,由表单层给 errorText。
|
||||||
|
double? parseWeightKgInput(String raw) {
|
||||||
|
final text = raw.trim();
|
||||||
|
if (!RegExp(r'^\d{1,3}(\.\d{1,2})?$').hasMatch(text)) return null;
|
||||||
|
final value = double.parse(text);
|
||||||
|
if (value <= 0 || value > 500) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 体重展示:去掉无意义尾零(4.35 → 4.35、5.00 → 5、4.50 → 4.5)。
|
||||||
|
String formatWeightKg(double weightKg) {
|
||||||
|
var text = weightKg.toStringAsFixed(2);
|
||||||
|
if (text.contains('.')) {
|
||||||
|
text = text.replaceFirst(RegExp(r'0+$'), '');
|
||||||
|
text = text.replaceFirst(RegExp(r'\.$'), '');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
String vaccinationStatusLabel(VaccinationStatus status) => switch (status) {
|
||||||
|
VaccinationStatus.scheduled => '计划中',
|
||||||
|
VaccinationStatus.completed => '已完成',
|
||||||
|
VaccinationStatus.cancelled => '已取消',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 状态标签基色(TagPill 淡染底;文字深变体由 TagPill 内置映射)。
|
||||||
|
Color vaccinationStatusColor(VaccinationStatus status) => switch (status) {
|
||||||
|
VaccinationStatus.scheduled => AppColors.accent,
|
||||||
|
VaccinationStatus.completed => AppColors.success,
|
||||||
|
VaccinationStatus.cancelled => AppColors.muted,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 剂次展示:doseLabel 优先,缺席回落「第 N 针」。
|
||||||
|
String vaccinationDoseLabel(Vaccination vaccination) =>
|
||||||
|
vaccination.doseLabel ?? '第 ${vaccination.doseNo} 针';
|
||||||
|
|
||||||
|
/// 疫苗条目副行:按状态给日期语义(计划 / 接种 + 下次到期 / 取消)。
|
||||||
|
String vaccinationDateLine(
|
||||||
|
Vaccination vaccination,
|
||||||
|
) => switch (vaccination.status) {
|
||||||
|
VaccinationStatus.scheduled =>
|
||||||
|
'计划 ${vaccination.plannedOn == null ? '未定' : dateToJson(vaccination.plannedOn!)}',
|
||||||
|
VaccinationStatus.completed =>
|
||||||
|
'接种 ${vaccination.administeredOn == null ? '—' : dateToJson(vaccination.administeredOn!)}'
|
||||||
|
'${vaccination.nextDueOn == null ? '' : ' · 下次 ${dateToJson(vaccination.nextDueOn!)}'}',
|
||||||
|
VaccinationStatus.cancelled => '已取消',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 摘要疫苗进度展示:契约 null 语义——无登记为 null(不是 0/0),
|
||||||
|
/// 调用方对 null 自行给空态文案。
|
||||||
|
String vaccinationProgressLabel(SummaryVaccinationProgress progress) =>
|
||||||
|
'${progress.completedDoses}/${progress.totalDoses}';
|
||||||
|
|
||||||
|
/// 疫苗状态-日期规则前端校验(契约 42201 规则的前置拦截;纯函数可单测):
|
||||||
|
/// scheduled 必有 plannedOn;completed 必有 administeredOn;
|
||||||
|
/// nextDueOn 与 administeredOn 同时存在时须 nextDueOn ≥ administeredOn。
|
||||||
|
/// 通过返回 null,违反返回给用户的拦截文案。
|
||||||
|
String? vaccinationDateRuleError({
|
||||||
|
required VaccinationStatus status,
|
||||||
|
DateTime? plannedOn,
|
||||||
|
DateTime? administeredOn,
|
||||||
|
DateTime? nextDueOn,
|
||||||
|
}) {
|
||||||
|
if (status == VaccinationStatus.scheduled && plannedOn == null) {
|
||||||
|
return '请选择计划接种日期';
|
||||||
|
}
|
||||||
|
if (status == VaccinationStatus.completed && administeredOn == null) {
|
||||||
|
return '请选择接种日期';
|
||||||
|
}
|
||||||
|
if (administeredOn != null &&
|
||||||
|
nextDueOn != null &&
|
||||||
|
nextDueOn.isBefore(
|
||||||
|
DateTime(administeredOn.year, administeredOn.month, administeredOn.day),
|
||||||
|
)) {
|
||||||
|
return '下次接种日期不能早于接种日期';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 健康事件(T2-14)----
|
||||||
|
|
||||||
|
/// 契约六类健康事件 → 视觉记录类型(RecordTypeDot 映射唯一出口)。
|
||||||
|
/// note(随手记)归入「其他」视觉族;其余五类各有专属图标。
|
||||||
|
RecordType recordTypeForHealthEvent(HealthEventType type) => switch (type) {
|
||||||
|
HealthEventType.medical => RecordType.medical,
|
||||||
|
HealthEventType.feeding => RecordType.feeding,
|
||||||
|
HealthEventType.deworming => RecordType.deworming,
|
||||||
|
HealthEventType.grooming => RecordType.grooming,
|
||||||
|
HealthEventType.measurement => RecordType.measurement,
|
||||||
|
HealthEventType.note => RecordType.other,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 六类事件中文文案(类型选择器 / 条目标签共用;
|
||||||
|
/// 图标 + 文字双通道,不单靠颜色区分)。
|
||||||
|
String healthEventTypeLabel(HealthEventType type) => switch (type) {
|
||||||
|
HealthEventType.medical => '就医',
|
||||||
|
HealthEventType.feeding => '喂养',
|
||||||
|
HealthEventType.deworming => '驱虫',
|
||||||
|
HealthEventType.grooming => '洗护',
|
||||||
|
HealthEventType.measurement => '测量',
|
||||||
|
HealthEventType.note => '随手记',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 事件发生时刻展示:本地时区 `YYYY-MM-DD HH:mm`。
|
||||||
|
String formatOccurredAt(DateTime occurredAt) {
|
||||||
|
final local = occurredAt.toLocal();
|
||||||
|
final h = local.hour.toString().padLeft(2, '0');
|
||||||
|
final min = local.minute.toString().padLeft(2, '0');
|
||||||
|
return '${dateToJson(local)} $h:$min';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间线月分组组头(05 §4.2:「2026 年 9 月」),按本地时区归月。
|
||||||
|
String healthEventMonthHeader(DateTime occurredAt) {
|
||||||
|
final local = occurredAt.toLocal();
|
||||||
|
return '${local.year} 年 ${local.month} 月';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设备时区 → summary `tz` 参数(契约接受固定偏移形如 `+08:00`;
|
||||||
|
/// Flutter 无 IANA 名可取,固定偏移语义等价——只作用于月度窗口)。
|
||||||
|
String tzOffsetQueryValue(Duration offset) {
|
||||||
|
final sign = offset.isNegative ? '-' : '+';
|
||||||
|
final abs = offset.abs();
|
||||||
|
final h = abs.inHours.toString().padLeft(2, '0');
|
||||||
|
final m = (abs.inMinutes % 60).toString().padLeft(2, '0');
|
||||||
|
return '$sign$h:$m';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 照护提醒(T2-14)----
|
||||||
|
|
||||||
|
/// 四类提醒中文文案。
|
||||||
|
String careReminderTypeLabel(CareReminderType type) => switch (type) {
|
||||||
|
CareReminderType.deworming => '驱虫',
|
||||||
|
CareReminderType.checkup => '体检',
|
||||||
|
CareReminderType.medication => '用药',
|
||||||
|
CareReminderType.other => '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 提醒类型 → 视觉记录类型(RecordTypeDot 复用):驱虫沿用驱虫族,
|
||||||
|
/// 体检/用药归就医族,其他归兜底族;类型文字由标签承载(双通道)。
|
||||||
|
RecordType recordTypeForReminder(CareReminderType type) => switch (type) {
|
||||||
|
CareReminderType.deworming => RecordType.deworming,
|
||||||
|
CareReminderType.checkup => RecordType.medical,
|
||||||
|
CareReminderType.medication => RecordType.medical,
|
||||||
|
CareReminderType.other => RecordType.other,
|
||||||
|
};
|
||||||
|
|
||||||
|
String careReminderStatusLabel(CareReminderStatus status) => switch (status) {
|
||||||
|
CareReminderStatus.pending => '待办',
|
||||||
|
CareReminderStatus.completed => '已完成',
|
||||||
|
CareReminderStatus.dismissed => '已忽略',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 逾期判定:待办且 dueAt 已过(工单硬项:逾期视觉标识)。
|
||||||
|
bool isReminderOverdue(CareReminder reminder, DateTime now) =>
|
||||||
|
reminder.status == CareReminderStatus.pending &&
|
||||||
|
reminder.dueAt.isBefore(now);
|
||||||
|
|
||||||
|
/// 提醒状态标签文案(逾期的待办以「已逾期」显性标识)。
|
||||||
|
String reminderStatusTag(CareReminder reminder, DateTime now) =>
|
||||||
|
isReminderOverdue(reminder, now)
|
||||||
|
? '已逾期'
|
||||||
|
: careReminderStatusLabel(reminder.status);
|
||||||
|
|
||||||
|
/// 提醒状态标签基色(TagPill 淡染底;文字深变体由 TagPill 内置映射)。
|
||||||
|
Color reminderStatusColor(CareReminder reminder, DateTime now) =>
|
||||||
|
switch (reminder.status) {
|
||||||
|
CareReminderStatus.pending =>
|
||||||
|
isReminderOverdue(reminder, now) ? AppColors.error : AppColors.accent,
|
||||||
|
CareReminderStatus.completed => AppColors.success,
|
||||||
|
CareReminderStatus.dismissed => AppColors.muted,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 提醒条目副行:按状态给时间语义(到期 / 完成于 / 已忽略)。
|
||||||
|
String reminderDateLine(CareReminder reminder) => switch (reminder.status) {
|
||||||
|
CareReminderStatus.pending => '到期 ${formatOccurredAt(reminder.dueAt)}',
|
||||||
|
CareReminderStatus.completed =>
|
||||||
|
'完成于 ${reminder.completedAt == null ? '—' : formatOccurredAt(reminder.completedAt!)}',
|
||||||
|
CareReminderStatus.dismissed =>
|
||||||
|
'已忽略 · 原到期 ${formatOccurredAt(reminder.dueAt)}',
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/// 金额换算工具:契约传输整数分(amountCents),UI 展示以元。
|
||||||
|
/// DTO 层保持整数分不换算(开发计划 §4.3);本工具供 UI 层(T2-14)使用。
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// 整数分 → 元展示字符串。整元不带小数("128"),
|
||||||
|
/// 非整元固定两位("128.50")。负数入参非法(契约金额非负)。
|
||||||
|
String formatCentsAsYuan(int cents) {
|
||||||
|
if (cents < 0) {
|
||||||
|
throw ArgumentError.value(cents, 'cents', '金额不得为负');
|
||||||
|
}
|
||||||
|
final yuan = cents ~/ 100;
|
||||||
|
final remainder = cents % 100;
|
||||||
|
if (remainder == 0) return '$yuan';
|
||||||
|
return '$yuan.${remainder.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 元输入串 → 整数分。接受整数或最多两位小数("12"、"12.5"、"12.50");
|
||||||
|
/// 空白、非法字符、超两位小数、负数返回 null(表单层据此报「金额格式不正确」)。
|
||||||
|
int? parseYuanToCents(String input) {
|
||||||
|
final trimmed = input.trim();
|
||||||
|
final match = RegExp(r'^(\d+)(?:\.(\d{1,2}))?$').firstMatch(trimmed);
|
||||||
|
if (match == null) return null;
|
||||||
|
final yuan = int.parse(match.group(1)!);
|
||||||
|
final fractional = match.group(2);
|
||||||
|
final cents = switch (fractional) {
|
||||||
|
null => 0,
|
||||||
|
_ => int.parse(fractional.padRight(2, '0')),
|
||||||
|
};
|
||||||
|
return yuan * 100 + cents;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
|
/// pet 域埋点强类型封装(06 号规划 §1.4/§4 字典 v2 三事件;沿用
|
||||||
|
/// 13 号规范 §3.1 惯例:枚举编译期锁死,业务代码禁止手拼事件名与属性)。
|
||||||
|
///
|
||||||
|
/// 三事件只覆盖「建宠」漏斗:`page_viewed(pet_form)` 承接到达段(路由
|
||||||
|
/// 埋点自动采集),started 在表单**首次输入**触发、每次进入记一次;
|
||||||
|
/// 编辑不设事件(06 §1.4 自觉取舍:短表单漏斗价值低于事件成本)。
|
||||||
|
|
||||||
|
/// 建宠表单入口(06 §4:pet_create_started.entryPoint 枚举)。
|
||||||
|
/// post_register_guide 预留:注册后引导流尚未落地,落地时启用。
|
||||||
|
enum PetCreateEntryPoint {
|
||||||
|
profileEmptyState('profile_empty_state'),
|
||||||
|
petList('pet_list'),
|
||||||
|
postRegisterGuide('post_register_guide');
|
||||||
|
|
||||||
|
const PetCreateEntryPoint(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建宠失败原因(06 §4 枚举;pet_limit_reached 因产品未设上限未纳入,
|
||||||
|
/// 若后续拍板设上限随字典增补)。客户端网络层不区分 5xx 与断网/超时
|
||||||
|
/// (均为 ApiNetworkException),两者并入 network_error,server_error
|
||||||
|
/// 保留给无法归类的兜底。
|
||||||
|
enum PetCreateFailureReason {
|
||||||
|
validationError('validation_error'),
|
||||||
|
rateLimited('rate_limited'),
|
||||||
|
networkError('network_error'),
|
||||||
|
serverError('server_error');
|
||||||
|
|
||||||
|
const PetCreateFailureReason(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PetAnalytics {
|
||||||
|
PetAnalytics(this._track);
|
||||||
|
|
||||||
|
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
|
||||||
|
final TrackEventFn _track;
|
||||||
|
|
||||||
|
/// 进入建宠表单并产生首次输入(每次进入记一次,表单层负责去重)。
|
||||||
|
void createStarted({required PetCreateEntryPoint entryPoint}) {
|
||||||
|
_track('pet_create_started', {'entryPoint': entryPoint.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建宠接口成功响应(漏斗事件)。
|
||||||
|
///
|
||||||
|
/// [durationMs] 为表单打开到成功的耗时;[petIndex] 为该用户第几只宠物
|
||||||
|
/// (H2 假设的直接数据源,与 pet.pets 事实表交叉验证)。
|
||||||
|
void createSucceeded({
|
||||||
|
required int durationMs,
|
||||||
|
required PetSpecies species,
|
||||||
|
required int petIndex,
|
||||||
|
}) {
|
||||||
|
_track('pet_create_succeeded', {
|
||||||
|
'durationMs': durationMs,
|
||||||
|
'species': species.name,
|
||||||
|
'petIndex': petIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建宠失败:失败响应 / 超时 / 本地校验拦截。
|
||||||
|
///
|
||||||
|
/// [errorCode] 为业务错误码(本地校验/网络错误时缺席);
|
||||||
|
/// [httpStatus] 由五位业务码推导(`code ~/ 100`);
|
||||||
|
/// [attemptSeq] 为本次表单会话内第几次提交尝试(从 1 起)。
|
||||||
|
void createFailed({
|
||||||
|
required PetCreateFailureReason reason,
|
||||||
|
required int attemptSeq,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('pet_create_failed', {
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'attemptSeq': attemptSeq,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/care_reminders_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_events_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/money.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/vaccination_records_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/weight_records_page.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
enum _DetailPhase { loading, ready, error, notFound }
|
||||||
|
|
||||||
|
enum _SummaryPhase { loading, ready, error }
|
||||||
|
|
||||||
|
enum _RemindersPhase { loading, ready, error }
|
||||||
|
|
||||||
|
/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。
|
||||||
|
///
|
||||||
|
/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet]
|
||||||
|
/// 拉取最新详情(同步列表副本)。四态:loading / ready / error+重试 /
|
||||||
|
/// notFound(40401 防枚举三态同响应 → 提示后返回列表并刷新)。
|
||||||
|
///
|
||||||
|
/// T2-13:数据卡行接 `GET /pets/{id}/summary` 实时聚合(最新体重 /
|
||||||
|
/// 疫苗进度 / 下一针,null 语义为空态文案而非 0/0),并作为体重历史、
|
||||||
|
/// 疫苗记录两页的导航入口;从记录页返回即重拉摘要。
|
||||||
|
class PetDetailPage extends StatefulWidget {
|
||||||
|
const PetDetailPage({
|
||||||
|
required this.controller,
|
||||||
|
required this.petId,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.healthAnalytics,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsController controller;
|
||||||
|
final String petId;
|
||||||
|
final PetAnalytics? analytics;
|
||||||
|
final HealthRecordAnalytics? healthAnalytics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PetDetailPage> createState() => _PetDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PetDetailPageState extends State<PetDetailPage> {
|
||||||
|
_DetailPhase _phase = _DetailPhase.loading;
|
||||||
|
Pet? _pet;
|
||||||
|
ApiException? _error;
|
||||||
|
|
||||||
|
_SummaryPhase _summaryPhase = _SummaryPhase.loading;
|
||||||
|
PetSummary? _summary;
|
||||||
|
|
||||||
|
_RemindersPhase _remindersPhase = _RemindersPhase.loading;
|
||||||
|
List<CareReminder> _pendingReminders = const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pet = _fromController();
|
||||||
|
if (_pet != null) _phase = _DetailPhase.ready;
|
||||||
|
_load();
|
||||||
|
_loadSummary();
|
||||||
|
_loadPendingReminders();
|
||||||
|
}
|
||||||
|
|
||||||
|
Pet? _fromController() {
|
||||||
|
for (final pet in widget.controller.pets) {
|
||||||
|
if (pet.id == widget.petId) return pet;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
if (_pet == null) {
|
||||||
|
setState(() => _phase = _DetailPhase.loading);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final pet = await widget.controller.getPet(widget.petId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pet = pet;
|
||||||
|
_phase = _DetailPhase.ready;
|
||||||
|
});
|
||||||
|
} on PetNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _phase = _DetailPhase.notFound);
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_pet == null) {
|
||||||
|
setState(() {
|
||||||
|
_error = error;
|
||||||
|
_phase = _DetailPhase.error;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 已有内存副本:刷新失败降级为瞬态提示,不打断阅读。
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。
|
||||||
|
/// `tz` 透传设备时区固定偏移(T2-13 遗留③):monthlyExpense 的月度
|
||||||
|
/// 窗口随设备时区取边界,与用户直觉一致。
|
||||||
|
Future<void> _loadSummary() async {
|
||||||
|
setState(() => _summaryPhase = _SummaryPhase.loading);
|
||||||
|
try {
|
||||||
|
final summary = await widget.controller.repository.getPetSummary(
|
||||||
|
widget.petId,
|
||||||
|
tz: tzOffsetQueryValue(DateTime.now().timeZoneOffset),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_summary = summary;
|
||||||
|
_summaryPhase = _SummaryPhase.ready;
|
||||||
|
});
|
||||||
|
} on ApiException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _summaryPhase = _SummaryPhase.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 待办提醒(?status=pending,due_at ASC):驱动「健康提醒」卡与
|
||||||
|
/// 提醒入口副行——demo 时代的硬编码提醒文案自此为真实数据取代。
|
||||||
|
/// 失败只降级为入口副行提示,不阻塞档案主链路。
|
||||||
|
Future<void> _loadPendingReminders() async {
|
||||||
|
setState(() => _remindersPhase = _RemindersPhase.loading);
|
||||||
|
try {
|
||||||
|
final reminders = await widget.controller.repository.listCareReminders(
|
||||||
|
widget.petId,
|
||||||
|
status: CareReminderStatus.pending,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pendingReminders = reminders;
|
||||||
|
_remindersPhase = _RemindersPhase.ready;
|
||||||
|
});
|
||||||
|
} on ApiException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _remindersPhase = _RemindersPhase.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEdit() async {
|
||||||
|
final pet = _pet;
|
||||||
|
if (pet == null) return;
|
||||||
|
final updated = await Navigator.of(context).push<Pet>(
|
||||||
|
// 编辑态不带路由名:pet_form 专属建宠漏斗到达段(06 §1.6),
|
||||||
|
// 编辑曝光不计入,避免「到达→动笔」分母虚高。
|
||||||
|
fadePageRoute(
|
||||||
|
PetFormPage.edit(
|
||||||
|
controller: widget.controller,
|
||||||
|
pet: pet,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (updated != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_pet = updated;
|
||||||
|
_phase = _DetailPhase.ready;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已保存修改')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅 owner 可改档案(40300:caregiver/viewer 改档案被拒 → 隐藏写入口)。
|
||||||
|
bool get _canEdit =>
|
||||||
|
_phase == _DetailPhase.ready && _pet?.myRole == PetRole.owner;
|
||||||
|
|
||||||
|
/// 记录写入权限档 WRITE(owner + caregiver);viewer 隐藏录入入口。
|
||||||
|
bool get _canWriteRecords => _pet?.myRole != PetRole.viewer;
|
||||||
|
|
||||||
|
Future<void> _openWeights(Pet pet) async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
// 列表页页名不在字典 v2 枚举内(06 §5.2 验收 4:字典外不上报),
|
||||||
|
// 曝光由 health_record_viewed 承载。
|
||||||
|
fadePageRoute(
|
||||||
|
WeightRecordsPage(
|
||||||
|
repository: widget.controller.repository,
|
||||||
|
petId: pet.id,
|
||||||
|
canWrite: _canWriteRecords,
|
||||||
|
analytics: widget.healthAnalytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// 记录可能已变化:返回即重拉摘要(服务端实时聚合是唯一事实来源)。
|
||||||
|
if (mounted) await _loadSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openVaccinations(Pet pet) async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
fadePageRoute(
|
||||||
|
VaccinationRecordsPage(
|
||||||
|
repository: widget.controller.repository,
|
||||||
|
petId: pet.id,
|
||||||
|
petSpecies: pet.species,
|
||||||
|
canWrite: _canWriteRecords,
|
||||||
|
analytics: widget.healthAnalytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) await _loadSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openTimeline(Pet pet) async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
fadePageRoute(
|
||||||
|
HealthEventsPage(
|
||||||
|
repository: widget.controller.repository,
|
||||||
|
petId: pet.id,
|
||||||
|
canWrite: _canWriteRecords,
|
||||||
|
analytics: widget.healthAnalytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// 事件可能已变化:返回即重拉摘要(月度花费实时聚合)。
|
||||||
|
if (mounted) await _loadSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openReminders(Pet pet) async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
fadePageRoute(
|
||||||
|
CareRemindersPage(
|
||||||
|
repository: widget.controller.repository,
|
||||||
|
petId: pet.id,
|
||||||
|
canWrite: _canWriteRecords,
|
||||||
|
analytics: widget.healthAnalytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// 待办可能已变化(完成/忽略/新建):返回即重拉待办。
|
||||||
|
if (mounted) await _loadPendingReminders();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
actions: [
|
||||||
|
if (_canEdit)
|
||||||
|
IconButton(
|
||||||
|
tooltip: '编辑资料',
|
||||||
|
onPressed: _openEdit,
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: switch (_phase) {
|
||||||
|
_DetailPhase.loading => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
_DetailPhase.error => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
InlineErrorBanner(message: petLoadErrorMessage(_error)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(onPressed: _load, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_DetailPhase.notFound => Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: EmptyStateIllustration(
|
||||||
|
icon: Icons.search_off_rounded,
|
||||||
|
title: '宠物不存在或已被删除',
|
||||||
|
description: '档案可能已被移除,返回列表查看最新档案',
|
||||||
|
ctaLabel: '返回列表',
|
||||||
|
onCtaPressed: () {
|
||||||
|
widget.controller.refresh();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_DetailPhase.ready => _content(_pet!),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _content(Pet pet) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 30),
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
PetAvatar(
|
||||||
|
size: PetAvatarSize.xl,
|
||||||
|
showEditBadge: _canEdit,
|
||||||
|
onTap: _canEdit ? _openEdit : null,
|
||||||
|
semanticLabel: _canEdit ? '编辑宠物资料' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
petMetaLine(pet),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
if (pet.status != PetStatus.active) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TagPill(
|
||||||
|
petStatusLabel(pet.status),
|
||||||
|
color: pet.status == PetStatus.lost
|
||||||
|
? AppColors.error
|
||||||
|
: AppColors.muted,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text('健康数据', style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_summarySection(pet),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_recordsSection(pet),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text('基本资料', style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SectionCard(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_InfoRow(label: '物种', value: petSpeciesLabel(pet.species)),
|
||||||
|
const _RowDivider(),
|
||||||
|
_InfoRow(label: '品种', value: petBreedLabel(pet)),
|
||||||
|
const _RowDivider(),
|
||||||
|
_InfoRow(label: '性别', value: petSexLabel(pet.sex)),
|
||||||
|
const _RowDivider(),
|
||||||
|
_InfoRow(
|
||||||
|
label: '生日',
|
||||||
|
value: pet.birthDate == null
|
||||||
|
? '未填写'
|
||||||
|
: dateToJson(pet.birthDate!) +
|
||||||
|
(pet.birthDateEstimated ? '(估算)' : ''),
|
||||||
|
),
|
||||||
|
const _RowDivider(),
|
||||||
|
_InfoRow(label: '芯片号', value: pet.microchipNo ?? '未填写'),
|
||||||
|
const _RowDivider(),
|
||||||
|
_InfoRow(label: '性格', value: pet.personality ?? '未填写'),
|
||||||
|
const _RowDivider(),
|
||||||
|
_InfoRow(
|
||||||
|
label: '绝育日期',
|
||||||
|
value: pet.sterilizedOn == null
|
||||||
|
? '未填写'
|
||||||
|
: dateToJson(pet.sterilizedOn!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_canEdit) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _openEdit,
|
||||||
|
icon: const Icon(Icons.edit_outlined, size: 17),
|
||||||
|
label: const Text('编辑资料'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录导航区:健康时间线与照护提醒入口(T2-14)。待办提醒非空时
|
||||||
|
/// 上方渲染真实数据驱动的「健康提醒」卡(正典 alert-card 形态,
|
||||||
|
/// 取代 demo 硬编码文案),点卡与点入口同去提醒页。
|
||||||
|
Widget _recordsSection(Pet pet) {
|
||||||
|
final nearest = _pendingReminders.isEmpty ? null : _pendingReminders.first;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (nearest != null) ...[
|
||||||
|
_ReminderAlertCard(
|
||||||
|
reminder: nearest,
|
||||||
|
overdue: isReminderOverdue(nearest, DateTime.now()),
|
||||||
|
onTap: () => _openReminders(pet),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
SectionCard(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.event_note_outlined,
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
title: const Text('健康时间线', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: const Text(
|
||||||
|
'就医 · 喂养 · 驱虫 · 洗护 · 测量 · 随手记',
|
||||||
|
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
onTap: () => _openTimeline(pet),
|
||||||
|
),
|
||||||
|
const Divider(height: 1, thickness: 1, color: AppColors.border),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.notifications_outlined,
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
title: const Text('照护提醒', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
switch (_remindersPhase) {
|
||||||
|
_RemindersPhase.loading => '加载中…',
|
||||||
|
_RemindersPhase.error => '提醒加载失败,点击查看',
|
||||||
|
_RemindersPhase.ready when _pendingReminders.isEmpty =>
|
||||||
|
'暂无待办提醒',
|
||||||
|
_RemindersPhase.ready => '${_pendingReminders.length} 条待办',
|
||||||
|
},
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
onTap: () => _openReminders(pet),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 数据卡行(05 §4.2 stat-row):四卡取数全部来自 summary 实时聚合,
|
||||||
|
/// 不落任何本地展示字符串(第 4.3 节红线)。null 语义 → 空态文案。
|
||||||
|
Widget _summarySection(Pet pet) {
|
||||||
|
switch (_summaryPhase) {
|
||||||
|
case _SummaryPhase.loading:
|
||||||
|
return const SizedBox(
|
||||||
|
height: 86,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case _SummaryPhase.error:
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'健康数据加载失败',
|
||||||
|
style: TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: _loadSummary, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
case _SummaryPhase.ready:
|
||||||
|
final summary = _summary!;
|
||||||
|
final weight = summary.latestWeight;
|
||||||
|
final progress = summary.vaccinationProgress;
|
||||||
|
final next = summary.nextVaccination;
|
||||||
|
final expense = summary.monthlyExpense;
|
||||||
|
return IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryCard(
|
||||||
|
icon: recordTypeStyles[RecordType.weight]!.icon,
|
||||||
|
iconColor: recordTypeStyles[RecordType.weight]!.iconColor,
|
||||||
|
value: weight == null
|
||||||
|
? '暂无记录'
|
||||||
|
: '${formatWeightKg(weight.weightKg)} kg',
|
||||||
|
emphasized: weight != null,
|
||||||
|
label: '最新体重',
|
||||||
|
onTap: () => _openWeights(pet),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryCard(
|
||||||
|
icon: recordTypeStyles[RecordType.vaccine]!.icon,
|
||||||
|
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
|
||||||
|
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
|
||||||
|
value: progress == null
|
||||||
|
? '未登记'
|
||||||
|
: '${vaccinationProgressLabel(progress)} 针',
|
||||||
|
emphasized: progress != null,
|
||||||
|
label: '疫苗进度',
|
||||||
|
onTap: () => _openVaccinations(pet),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryCard(
|
||||||
|
icon: recordTypeStyles[RecordType.vaccine]!.icon,
|
||||||
|
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
|
||||||
|
value: next == null ? '暂无安排' : dateToJson(next.dueOn),
|
||||||
|
emphasized: next != null,
|
||||||
|
label: next == null ? '下一针' : '下一针·${next.vaccineName}',
|
||||||
|
onTap: () => _openVaccinations(pet),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryCard(
|
||||||
|
icon: Icons.payments_outlined,
|
||||||
|
iconColor: AppColors.primaryStrong,
|
||||||
|
// monthlyExpense 恒非 null(契约);金额整数分 → 元展示。
|
||||||
|
value: '¥${formatCentsAsYuan(expense.amountCents)}',
|
||||||
|
emphasized: expense.amountCents > 0,
|
||||||
|
label: '本月花费',
|
||||||
|
onTap: () => _openTimeline(pet),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft;
|
||||||
|
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
|
||||||
|
class _SummaryCard extends StatelessWidget {
|
||||||
|
const _SummaryCard({
|
||||||
|
required this.icon,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.value,
|
||||||
|
required this.label,
|
||||||
|
required this.emphasized,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color iconColor;
|
||||||
|
final String value;
|
||||||
|
final String label;
|
||||||
|
final bool emphasized;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: iconColor),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: emphasized ? AppColors.ink : AppColors.inkSoft,
|
||||||
|
fontSize: emphasized ? 15 : 13,
|
||||||
|
fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 键值行(05 §4.3 P3 风格:字段名 12 inkSoft / 值 bodyMedium ink)。
|
||||||
|
class _InfoRow extends StatelessWidget {
|
||||||
|
const _InfoRow({required this.label, required this.value});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 72,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(value, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RowDivider extends StatelessWidget {
|
||||||
|
const _RowDivider();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Divider(height: 1, thickness: 1, color: AppColors.border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「健康提醒」卡(正典 alert-card:successSurface 底 + dot + 文字):
|
||||||
|
/// 数据源为最近到期的待办提醒(真实数据驱动,取代 demo 硬编码文案);
|
||||||
|
/// 逾期时文案切警示深色(双通道:前缀文字 + 颜色)。
|
||||||
|
class _ReminderAlertCard extends StatelessWidget {
|
||||||
|
const _ReminderAlertCard({
|
||||||
|
required this.reminder,
|
||||||
|
required this.overdue,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CareReminder reminder;
|
||||||
|
final bool overdue;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final textColor = overdue ? AppColors.errorDark : AppColors.successInk;
|
||||||
|
return Material(
|
||||||
|
color: AppColors.successSurface,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: textColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'健康提醒:${reminder.title}'
|
||||||
|
'(${overdue ? '已逾期' : '${dateToJson(reminder.dueAt.toLocal())} 到期'})',
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.chevron_right, size: 18, color: textColor),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/// 宠物档案展示文案的纯函数集合(列表 / 详情 / 表单共用,可单测)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
|
String petSpeciesLabel(PetSpecies species) => switch (species) {
|
||||||
|
PetSpecies.dog => '狗狗',
|
||||||
|
PetSpecies.cat => '猫咪',
|
||||||
|
PetSpecies.other => '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
String petSexLabel(PetSex sex) => switch (sex) {
|
||||||
|
PetSex.male => '男孩',
|
||||||
|
PetSex.female => '女孩',
|
||||||
|
PetSex.unknown => '性别未知',
|
||||||
|
};
|
||||||
|
|
||||||
|
String petStatusLabel(PetStatus status) => switch (status) {
|
||||||
|
PetStatus.active => '正常',
|
||||||
|
PetStatus.lost => '走失中',
|
||||||
|
PetStatus.deceased => '已离世',
|
||||||
|
PetStatus.archived => '已归档',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 品种展示:目录品种名 > 自定义品种名 > 未知(Pet 响应恰有其一非空,
|
||||||
|
/// 双空为兜底)。
|
||||||
|
String petBreedLabel(Pet pet) =>
|
||||||
|
pet.breedDisplayName ?? pet.customBreedName ?? '品种未知';
|
||||||
|
|
||||||
|
/// 年龄:满一岁按「N 岁」、不满一岁按「N 个月」、当月新生「未满月」;
|
||||||
|
/// 生日缺席或晚于今天为「年龄未知」。
|
||||||
|
String petAgeLabel(DateTime? birthDate, {DateTime? now}) {
|
||||||
|
if (birthDate == null) return '年龄未知';
|
||||||
|
final today = now ?? DateTime.now();
|
||||||
|
var months =
|
||||||
|
(today.year - birthDate.year) * 12 + today.month - birthDate.month;
|
||||||
|
if (today.day < birthDate.day) months--;
|
||||||
|
if (months < 0) return '年龄未知';
|
||||||
|
if (months == 0) return '未满月';
|
||||||
|
if (months < 12) return '$months 个月';
|
||||||
|
return '${months ~/ 12} 岁';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表卡 / 详情头的元信息行:「柴犬 · 男孩 · 2 岁」。
|
||||||
|
String petMetaLine(Pet pet, {DateTime? now}) =>
|
||||||
|
'${petBreedLabel(pet)} · ${petSexLabel(pet.sex)} · '
|
||||||
|
'${petAgeLabel(pet.birthDate, now: now)}';
|
||||||
|
|
||||||
|
/// 加载失败的用户文案(一迭代三层错误模型:不可归属字段的加载错误
|
||||||
|
/// 走横幅;服务端原始 message 不上屏)。
|
||||||
|
String petLoadErrorMessage(ApiException? error) => switch (error) {
|
||||||
|
ApiNetworkException _ => '网络异常,请检查网络后重试',
|
||||||
|
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
|
||||||
|
_ => '加载失败,请稍后重试',
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
|
||||||
|
/// pets 域类型化业务异常(契约 v1.2.0 定型的 8 个新错误码,
|
||||||
|
/// 21 号收口报告 §1)。全部继承 [ApiBusinessException],
|
||||||
|
/// 既有按基类捕获的通用错误处理不受影响。
|
||||||
|
|
||||||
|
/// 40300:对可见宠物无相应操作权限(viewer 写记录、caregiver/viewer 改档案)。
|
||||||
|
final class PetAccessDeniedException extends ApiBusinessException {
|
||||||
|
const PetAccessDeniedException({required super.message})
|
||||||
|
: super(code: ApiCodes.petAccessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40401:宠物不存在 / 已软删 / 与调用者无关系(防枚举,三种情况响应一致)。
|
||||||
|
final class PetNotFoundException extends ApiBusinessException {
|
||||||
|
const PetNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.petNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40402:记录不存在或记录所属宠物对调用者不可见(记录级防枚举)。
|
||||||
|
final class PetRecordNotFoundException extends ApiBusinessException {
|
||||||
|
const PetRecordNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.recordNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40902:乐观锁版本冲突(version 过期;提醒的条件更新守卫落空同码)。
|
||||||
|
/// 客户端处理:刷新取新数据后重提。
|
||||||
|
final class PetVersionConflictException extends ApiBusinessException {
|
||||||
|
const PetVersionConflictException({required super.message})
|
||||||
|
: super(code: ApiCodes.versionConflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40903:芯片号已被登记(跨用户唯一)。
|
||||||
|
final class MicrochipTakenException extends ApiBusinessException {
|
||||||
|
const MicrochipTakenException({required super.message})
|
||||||
|
: super(code: ApiCodes.microchipTaken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40904:同宠物同疫苗同系列同剂次的非 cancelled 疫苗记录已存在
|
||||||
|
/// (cancel 后同剂次可重新登记)。
|
||||||
|
final class VaccinationDoseExistsException extends ApiBusinessException {
|
||||||
|
const VaccinationDoseExistsException({required super.message})
|
||||||
|
: super(code: ApiCodes.vaccinationDoseExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42201:疫苗状态机非法迁移或状态-日期规则违反(HTTP 422)。
|
||||||
|
final class VaccinationRuleException extends ApiBusinessException {
|
||||||
|
const VaccinationRuleException({required super.message})
|
||||||
|
: super(code: ApiCodes.vaccinationRuleViolation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42202:提醒状态机非法迁移或 completed-completedAt 一致性违反(HTTP 422)。
|
||||||
|
final class CareReminderRuleException extends ApiBusinessException {
|
||||||
|
const CareReminderRuleException({required super.message})
|
||||||
|
: super(code: ApiCodes.careReminderRuleViolation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把通用业务异常按 pets 域错误码升格为类型化异常;
|
||||||
|
/// 未覆盖的码(40000 参数错误等)原样返回,沿用通用处理。
|
||||||
|
ApiBusinessException mapPetBusinessException(ApiBusinessException error) {
|
||||||
|
return switch (error.code) {
|
||||||
|
ApiCodes.petAccessDenied => PetAccessDeniedException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.petNotFound => PetNotFoundException(message: error.message),
|
||||||
|
ApiCodes.recordNotFound => PetRecordNotFoundException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.versionConflict => PetVersionConflictException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.microchipTaken => MicrochipTakenException(message: error.message),
|
||||||
|
ApiCodes.vaccinationDoseExists => VaccinationDoseExistsException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.vaccinationRuleViolation => VaccinationRuleException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.careReminderRuleViolation => CareReminderRuleException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
_ => error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,714 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_controller.dart';
|
||||||
|
|
||||||
|
/// 品种下拉里「自定义品种」哨兵值(与目录 breedId 互斥)。
|
||||||
|
const _customBreedSentinel = '__custom__';
|
||||||
|
|
||||||
|
/// 建宠 / 编辑宠物资料表单页(T2-12)。
|
||||||
|
///
|
||||||
|
/// - 字段对齐冻结契约:昵称*、物种*(创建后不可改)、性别*(契约必填)、
|
||||||
|
/// 品种(目录选择与自定义互斥、整体替换)、生日(+是否估算)、
|
||||||
|
/// 芯片号、性格;头像按 ADR-010 本地占位、不做上传。
|
||||||
|
/// - 错误分层沿用登录纵切:失焦+提交双校验走 errorText(onChanged 即清)、
|
||||||
|
/// 不可归属错误走 InlineErrorBanner、网络瞬态走 SnackBar+重试。
|
||||||
|
/// - 409/40902 版本冲突:提示「已被修改」并自动拉取最新版本(保留用户
|
||||||
|
/// 输入、更新乐观锁 version),用户核对后重新保存;40903 芯片号冲突
|
||||||
|
/// 为字段级报错。
|
||||||
|
/// - 埋点(仅创建模式):首次输入触发 pet_create_started;成功/失败
|
||||||
|
/// 经 [PetAnalytics] 强类型上报。表单页路由名 pet_form 由调用方在
|
||||||
|
/// push 时给定(创建态),page_viewed 走既有 RouteObserver。
|
||||||
|
class PetFormPage extends StatefulWidget {
|
||||||
|
const PetFormPage.create({
|
||||||
|
required this.controller,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.entryPoint = PetCreateEntryPoint.petList,
|
||||||
|
}) : pet = null;
|
||||||
|
|
||||||
|
const PetFormPage.edit({
|
||||||
|
required this.controller,
|
||||||
|
required Pet this.pet,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
}) : entryPoint = null;
|
||||||
|
|
||||||
|
final PetsController controller;
|
||||||
|
|
||||||
|
/// null 为创建模式。
|
||||||
|
final Pet? pet;
|
||||||
|
|
||||||
|
final PetAnalytics? analytics;
|
||||||
|
|
||||||
|
/// 创建模式的入口(pet_create_started.entryPoint)。
|
||||||
|
final PetCreateEntryPoint? entryPoint;
|
||||||
|
|
||||||
|
bool get isCreate => pet == null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PetFormPage> createState() => _PetFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PetFormPageState extends State<PetFormPage> {
|
||||||
|
final _nameCtrl = TextEditingController();
|
||||||
|
final _customBreedCtrl = TextEditingController();
|
||||||
|
final _microchipCtrl = TextEditingController();
|
||||||
|
final _personalityCtrl = TextEditingController();
|
||||||
|
|
||||||
|
PetSpecies _species = PetSpecies.dog;
|
||||||
|
PetSex? _sex;
|
||||||
|
DateTime? _birthDate;
|
||||||
|
bool _birthDateEstimated = false;
|
||||||
|
|
||||||
|
/// 目录 breedId 或 [_customBreedSentinel];null 未选择。
|
||||||
|
String? _breedChoice;
|
||||||
|
List<Breed>? _breeds;
|
||||||
|
bool _breedsLoading = false;
|
||||||
|
bool _breedsFailed = false;
|
||||||
|
|
||||||
|
String? _nameError;
|
||||||
|
String? _sexError;
|
||||||
|
String? _breedError;
|
||||||
|
String? _microchipError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
/// 编辑基线:40902 冲突刷新后更新(version 与差量计算的比较基准)。
|
||||||
|
Pet? _basePet;
|
||||||
|
|
||||||
|
bool _startedFired = false;
|
||||||
|
int _attemptSeq = 0;
|
||||||
|
late final DateTime _openedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_openedAt = DateTime.now();
|
||||||
|
final pet = widget.pet;
|
||||||
|
_basePet = pet;
|
||||||
|
if (pet != null) {
|
||||||
|
_nameCtrl.text = pet.name;
|
||||||
|
_species = pet.species;
|
||||||
|
_sex = pet.sex;
|
||||||
|
_birthDate = pet.birthDate;
|
||||||
|
_birthDateEstimated = pet.birthDateEstimated;
|
||||||
|
_microchipCtrl.text = pet.microchipNo ?? '';
|
||||||
|
_personalityCtrl.text = pet.personality ?? '';
|
||||||
|
if (pet.customBreedName != null) {
|
||||||
|
_breedChoice = _customBreedSentinel;
|
||||||
|
_customBreedCtrl.text = pet.customBreedName!;
|
||||||
|
} else {
|
||||||
|
_breedChoice = pet.breedId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_loadBreeds();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
_customBreedCtrl.dispose();
|
||||||
|
_microchipCtrl.dispose();
|
||||||
|
_personalityCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 品种目录(网络字典:加载 / 失败重试 / 空目录自定义兜底)----
|
||||||
|
|
||||||
|
Future<void> _loadBreeds() async {
|
||||||
|
setState(() {
|
||||||
|
_breedsLoading = true;
|
||||||
|
_breedsFailed = false;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final breeds = await widget.controller.loadBreeds(_species);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_breeds = breeds;
|
||||||
|
_breedsLoading = false;
|
||||||
|
});
|
||||||
|
} on ApiException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_breedsLoading = false;
|
||||||
|
_breedsFailed = true;
|
||||||
|
// 目录不可用不阻塞建档:回落到自定义品种输入。
|
||||||
|
_breedChoice ??= _customBreedSentinel;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 埋点(仅创建模式)----
|
||||||
|
|
||||||
|
void _markStarted() {
|
||||||
|
if (!widget.isCreate || _startedFired) return;
|
||||||
|
_startedFired = true;
|
||||||
|
widget.analytics?.createStarted(entryPoint: widget.entryPoint!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackCreateFailed(PetCreateFailureReason reason, [int? errorCode]) {
|
||||||
|
if (!widget.isCreate) return;
|
||||||
|
widget.analytics?.createFailed(
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: _attemptSeq,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 校验(失焦 + 提交双校验,onChanged 即清)----
|
||||||
|
|
||||||
|
String? _validateName() => _nameCtrl.text.trim().isEmpty ? '请输入宠物昵称' : null;
|
||||||
|
|
||||||
|
String? _validateSex() => _sex == null ? '请选择性别' : null;
|
||||||
|
|
||||||
|
String? _validateBreed() {
|
||||||
|
if (_breedChoice == null) return '请选择品种';
|
||||||
|
if (_breedChoice == _customBreedSentinel &&
|
||||||
|
_customBreedCtrl.text.trim().isEmpty) {
|
||||||
|
return '请输入品种名称';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearError(void Function() clear) {
|
||||||
|
setState(() {
|
||||||
|
clear();
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 提交 ----
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
_attemptSeq++;
|
||||||
|
final nameError = _validateName();
|
||||||
|
final sexError = _validateSex();
|
||||||
|
final breedError = _validateBreed();
|
||||||
|
if (nameError != null || sexError != null || breedError != null) {
|
||||||
|
setState(() {
|
||||||
|
_nameError = nameError;
|
||||||
|
_sexError = sexError;
|
||||||
|
_breedError = breedError;
|
||||||
|
});
|
||||||
|
_trackCreateFailed(PetCreateFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (widget.isCreate) {
|
||||||
|
await _create();
|
||||||
|
} else {
|
||||||
|
await _update();
|
||||||
|
}
|
||||||
|
} on MicrochipTakenException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _microchipError = '该芯片号已被登记,请核对后重试');
|
||||||
|
_trackCreateFailed(PetCreateFailureReason.validationError, 40903);
|
||||||
|
} on PetVersionConflictException {
|
||||||
|
await _handleVersionConflict();
|
||||||
|
} on PetNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 编辑目标已不存在(防枚举三态同响应):返回列表并刷新。
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
|
||||||
|
widget.controller.refresh();
|
||||||
|
navigator.pop();
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('你没有权限修改该宠物的资料');
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('操作过于频繁,请稍后再试');
|
||||||
|
_trackCreateFailed(PetCreateFailureReason.rateLimited);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError(
|
||||||
|
error.code == ApiCodes.paramError ? '请检查填写内容后重试' : '保存失败,请稍后重试',
|
||||||
|
);
|
||||||
|
_trackCreateFailed(
|
||||||
|
error.code == ApiCodes.paramError
|
||||||
|
? PetCreateFailureReason.validationError
|
||||||
|
: PetCreateFailureReason.serverError,
|
||||||
|
error.code,
|
||||||
|
);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_trackCreateFailed(PetCreateFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页,表单不再提示。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isCustomBreed => _breedChoice == _customBreedSentinel;
|
||||||
|
|
||||||
|
Future<void> _create() async {
|
||||||
|
final request = CreatePetRequest(
|
||||||
|
name: _nameCtrl.text.trim(),
|
||||||
|
species: _species,
|
||||||
|
sex: _sex!,
|
||||||
|
breedId: _isCustomBreed ? null : _breedChoice,
|
||||||
|
customBreedName: _isCustomBreed ? _customBreedCtrl.text.trim() : null,
|
||||||
|
birthDate: _birthDate,
|
||||||
|
birthDateEstimated: _birthDate != null ? _birthDateEstimated : null,
|
||||||
|
microchipNo: _textOrNull(_microchipCtrl),
|
||||||
|
personality: _textOrNull(_personalityCtrl),
|
||||||
|
);
|
||||||
|
// petIndex:该用户第几只宠物(H2 假设数据源)。
|
||||||
|
final petIndex = widget.controller.pets.length + 1;
|
||||||
|
final pet = await widget.controller.createPet(request);
|
||||||
|
widget.analytics?.createSucceeded(
|
||||||
|
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
|
||||||
|
species: _species,
|
||||||
|
petIndex: petIndex,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(pet);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _update() async {
|
||||||
|
final request = _buildUpdateRequest();
|
||||||
|
if (request == null) {
|
||||||
|
// 无变更:直接返回,不发空 PATCH。
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final pet = await widget.controller.updatePet(_basePet!.id, request);
|
||||||
|
if (mounted) Navigator.of(context).pop(pet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 差量构造部分更新请求(缺席字段不发;契约不支持清空回 null,
|
||||||
|
/// 清空的输入视为未变更)。全部未变更返回 null。
|
||||||
|
UpdatePetRequest? _buildUpdateRequest() {
|
||||||
|
final base = _basePet!;
|
||||||
|
final name = _nameCtrl.text.trim();
|
||||||
|
|
||||||
|
final String? breedId = _isCustomBreed ? null : _breedChoice;
|
||||||
|
final String? customName = _isCustomBreed
|
||||||
|
? _customBreedCtrl.text.trim()
|
||||||
|
: null;
|
||||||
|
final breedChanged =
|
||||||
|
breedId != base.breedId || customName != base.customBreedName;
|
||||||
|
|
||||||
|
final microchip = _textOrNull(_microchipCtrl);
|
||||||
|
final personality = _textOrNull(_personalityCtrl);
|
||||||
|
final birthChanged =
|
||||||
|
_birthDate != null && !_sameDate(_birthDate, base.birthDate);
|
||||||
|
final estimatedChanged =
|
||||||
|
_birthDate != null && _birthDateEstimated != base.birthDateEstimated;
|
||||||
|
|
||||||
|
final request = UpdatePetRequest(
|
||||||
|
version: base.version,
|
||||||
|
name: name != base.name ? name : null,
|
||||||
|
sex: _sex != base.sex ? _sex : null,
|
||||||
|
// 品种对整体替换:任一半变化则整对发送。
|
||||||
|
breedId: breedChanged ? breedId : null,
|
||||||
|
customBreedName: breedChanged ? customName : null,
|
||||||
|
birthDate: birthChanged ? _birthDate : null,
|
||||||
|
birthDateEstimated: estimatedChanged ? _birthDateEstimated : null,
|
||||||
|
microchipNo: microchip != null && microchip != base.microchipNo
|
||||||
|
? microchip
|
||||||
|
: null,
|
||||||
|
personality: personality != null && personality != base.personality
|
||||||
|
? personality
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
// 只剩 version 一个键 → 无实际变更。
|
||||||
|
return request.toJson().length == 1 ? null : request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40902:提示 + 刷新路径——拉取最新版本更新乐观锁基线,
|
||||||
|
/// 保留用户输入,由用户核对后重新保存(T2-11 §7 定型路径)。
|
||||||
|
Future<void> _handleVersionConflict() async {
|
||||||
|
try {
|
||||||
|
final fresh = await widget.controller.getPet(_basePet!.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _basePet = fresh);
|
||||||
|
_showFormError('资料已在其他设备被修改,已获取最新版本,请核对后重新保存');
|
||||||
|
} on ApiException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('资料已在其他设备被修改,请返回后刷新重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _textOrNull(TextEditingController controller) {
|
||||||
|
final text = controller.text.trim();
|
||||||
|
return text.isEmpty ? null : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _sameDate(DateTime? a, DateTime? b) {
|
||||||
|
if (a == null || b == null) return a == b;
|
||||||
|
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UI ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: Text(widget.isCreate ? '添加宠物' : '编辑宠物资料'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 30),
|
||||||
|
children: [
|
||||||
|
// 头像本地占位:M2 不做上传(ADR-010 / D2-1)。
|
||||||
|
const Center(child: PetAvatar(size: PetAvatarSize.xl)),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Focus(
|
||||||
|
onFocusChange: (hasFocus) {
|
||||||
|
if (!hasFocus && mounted) {
|
||||||
|
setState(() => _nameError = _validateName());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: AppTextField(
|
||||||
|
label: '宠物昵称',
|
||||||
|
controller: _nameCtrl,
|
||||||
|
prefixIcon: Icons.pets_outlined,
|
||||||
|
errorText: _nameError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_nameError != null || _formError != null) {
|
||||||
|
_clearError(() => _nameError = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_FieldLabel(widget.isCreate ? '物种' : '物种(创建后不可修改)'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (widget.isCreate)
|
||||||
|
SegmentedButton<PetSpecies>(
|
||||||
|
segments: [
|
||||||
|
for (final species in PetSpecies.values)
|
||||||
|
ButtonSegment(
|
||||||
|
value: species,
|
||||||
|
label: Text(petSpeciesLabel(species)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_species},
|
||||||
|
onSelectionChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_species = value.first;
|
||||||
|
// 物种切换:品种目录随物种重载,已选品种作废。
|
||||||
|
_breedChoice = null;
|
||||||
|
_breedError = null;
|
||||||
|
_breeds = null;
|
||||||
|
});
|
||||||
|
_loadBreeds();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
petSpeciesLabel(_species),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
const _FieldLabel('性别'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<PetSex>(
|
||||||
|
emptySelectionAllowed: true,
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: PetSex.male,
|
||||||
|
icon: Icon(Icons.male),
|
||||||
|
label: Text('男孩'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: PetSex.female,
|
||||||
|
icon: Icon(Icons.female),
|
||||||
|
label: Text('女孩'),
|
||||||
|
),
|
||||||
|
ButtonSegment(value: PetSex.unknown, label: Text('未知')),
|
||||||
|
],
|
||||||
|
selected: {?_sex},
|
||||||
|
onSelectionChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_sex = value.isEmpty ? null : value.first;
|
||||||
|
_sexError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_sexError != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_FieldError(_sexError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
const _FieldLabel('品种'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._breedSection(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_dateTile(),
|
||||||
|
if (_birthDate != null)
|
||||||
|
CheckboxListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
title: const Text('生日为估算日期', style: TextStyle(fontSize: 13)),
|
||||||
|
value: _birthDateEstimated,
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() => _birthDateEstimated = value ?? false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppTextField(
|
||||||
|
label: '芯片号(可选)',
|
||||||
|
controller: _microchipCtrl,
|
||||||
|
prefixIcon: Icons.qr_code_2_outlined,
|
||||||
|
errorText: _microchipError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_microchipError != null || _formError != null) {
|
||||||
|
_clearError(() => _microchipError = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppTextField(
|
||||||
|
label: '性格(可选,如:活泼)',
|
||||||
|
controller: _personalityCtrl,
|
||||||
|
prefixIcon: Icons.emoji_emotions_outlined,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (_) => _markStarted(),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: widget.isCreate ? '保存档案' : '保存修改',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _breedSection() {
|
||||||
|
if (_breedsLoading) {
|
||||||
|
return const [
|
||||||
|
SizedBox(
|
||||||
|
height: 52,
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
if (_breedsFailed) {
|
||||||
|
widgets
|
||||||
|
..add(
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'品种目录加载失败,可先填写自定义品种',
|
||||||
|
style: TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: _loadBreeds, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(const SizedBox(height: 8));
|
||||||
|
} else {
|
||||||
|
final breeds = _breeds ?? const <Breed>[];
|
||||||
|
final knownIds = breeds.map((breed) => breed.id).toSet();
|
||||||
|
final base = _basePet;
|
||||||
|
widgets
|
||||||
|
..add(
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _breedChoice,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '品种',
|
||||||
|
errorText: _isCustomBreed ? null : _breedError,
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
// 编辑时目录中缺席的既有品种保底成项,避免下拉值失配。
|
||||||
|
if (base?.breedId != null && !knownIds.contains(base!.breedId))
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: base.breedId,
|
||||||
|
child: Text(base.breedDisplayName ?? '当前品种'),
|
||||||
|
),
|
||||||
|
for (final breed in breeds)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: breed.id,
|
||||||
|
child: Text(breed.displayName),
|
||||||
|
),
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: _customBreedSentinel,
|
||||||
|
child: Text('自定义品种…'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_breedChoice = value;
|
||||||
|
_breedError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(const SizedBox(height: 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isCustomBreed) {
|
||||||
|
widgets.add(
|
||||||
|
Focus(
|
||||||
|
onFocusChange: (hasFocus) {
|
||||||
|
if (!hasFocus && mounted) {
|
||||||
|
setState(() => _breedError = _validateBreed());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: AppTextField(
|
||||||
|
label: '品种名称',
|
||||||
|
controller: _customBreedCtrl,
|
||||||
|
prefixIcon: Icons.edit_note_outlined,
|
||||||
|
errorText: _breedError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_breedError != null || _formError != null) {
|
||||||
|
_clearError(() => _breedError = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (_breedError != null && _breedsFailed) {
|
||||||
|
widgets.add(_FieldError(_breedError!));
|
||||||
|
}
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dateTile() {
|
||||||
|
return ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
),
|
||||||
|
tileColor: AppColors.surface,
|
||||||
|
leading: const Icon(Icons.cake_outlined, color: AppColors.muted),
|
||||||
|
title: const Text('生日(可选)', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
_birthDate == null ? '未填写' : dateToJson(_birthDate!),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.calendar_month_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
enabled: !_submitting,
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final value = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _birthDate ?? DateTime(now.year - 1, now.month),
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: now,
|
||||||
|
);
|
||||||
|
if (value != null && mounted) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() => _birthDate = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FieldLabel extends StatelessWidget {
|
||||||
|
const _FieldLabel(this.text);
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FieldError extends StatelessWidget {
|
||||||
|
const _FieldError(this.text);
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
/// pets 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.2.0,
|
||||||
|
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
|
||||||
|
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'package:patbond_flutter/core/models/cursor_page.dart';
|
||||||
|
|
||||||
|
/// 物种(创建即定,不可修改)。
|
||||||
|
enum PetSpecies {
|
||||||
|
dog,
|
||||||
|
cat,
|
||||||
|
other;
|
||||||
|
|
||||||
|
static PetSpecies fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'species');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 性别。
|
||||||
|
enum PetSex {
|
||||||
|
male,
|
||||||
|
female,
|
||||||
|
unknown;
|
||||||
|
|
||||||
|
static PetSex fromJson(String value) => _enumFromJson(values, value, 'sex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宠物状态(deleted 为内部软删态,接口永不返回)。
|
||||||
|
enum PetStatus {
|
||||||
|
active,
|
||||||
|
lost,
|
||||||
|
deceased,
|
||||||
|
archived;
|
||||||
|
|
||||||
|
static PetStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调用者对宠物的权限角色(客户端据此显隐写入口)。
|
||||||
|
enum PetRole {
|
||||||
|
owner,
|
||||||
|
caregiver,
|
||||||
|
viewer;
|
||||||
|
|
||||||
|
static PetRole fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'myRole');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 体重记录来源。
|
||||||
|
enum WeightSource {
|
||||||
|
manual,
|
||||||
|
clinic,
|
||||||
|
device;
|
||||||
|
|
||||||
|
static WeightSource fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'source');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 疫苗记录状态。
|
||||||
|
enum VaccinationStatus {
|
||||||
|
scheduled,
|
||||||
|
completed,
|
||||||
|
cancelled;
|
||||||
|
|
||||||
|
static VaccinationStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 健康事件六类类型。
|
||||||
|
enum HealthEventType {
|
||||||
|
medical,
|
||||||
|
feeding,
|
||||||
|
deworming,
|
||||||
|
grooming,
|
||||||
|
measurement,
|
||||||
|
note;
|
||||||
|
|
||||||
|
static HealthEventType fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'eventType');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 照护提醒四类类型。
|
||||||
|
enum CareReminderType {
|
||||||
|
deworming,
|
||||||
|
checkup,
|
||||||
|
medication,
|
||||||
|
other;
|
||||||
|
|
||||||
|
static CareReminderType fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'reminderType');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 照护提醒状态。
|
||||||
|
enum CareReminderStatus {
|
||||||
|
pending,
|
||||||
|
completed,
|
||||||
|
dismissed;
|
||||||
|
|
||||||
|
static CareReminderStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要「下次接种」的取值来源。
|
||||||
|
enum NextVaccinationSource {
|
||||||
|
planned,
|
||||||
|
nextDue;
|
||||||
|
|
||||||
|
static NextVaccinationSource fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'source');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日期字段(契约 format: date)序列化为 YYYY-MM-DD。
|
||||||
|
String dateToJson(DateTime date) {
|
||||||
|
final y = date.year.toString().padLeft(4, '0');
|
||||||
|
final m = date.month.toString().padLeft(2, '0');
|
||||||
|
final d = date.day.toString().padLeft(2, '0');
|
||||||
|
return '$y-$m-$d';
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _dateOrNull(Object? value) =>
|
||||||
|
value == null ? null : DateTime.parse(value as String);
|
||||||
|
|
||||||
|
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
|
||||||
|
/// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。
|
||||||
|
class Pet {
|
||||||
|
const Pet({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.species,
|
||||||
|
required this.breedId,
|
||||||
|
required this.breedDisplayName,
|
||||||
|
required this.customBreedName,
|
||||||
|
required this.sex,
|
||||||
|
required this.birthDate,
|
||||||
|
required this.birthDateEstimated,
|
||||||
|
required this.personality,
|
||||||
|
required this.microchipNo,
|
||||||
|
required this.sterilizedOn,
|
||||||
|
required this.status,
|
||||||
|
required this.myRole,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Pet.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Pet(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
species: PetSpecies.fromJson(json['species'] as String),
|
||||||
|
breedId: json['breedId'] as String?,
|
||||||
|
breedDisplayName: json['breedDisplayName'] as String?,
|
||||||
|
customBreedName: json['customBreedName'] as String?,
|
||||||
|
sex: PetSex.fromJson(json['sex'] as String),
|
||||||
|
birthDate: _dateOrNull(json['birthDate']),
|
||||||
|
birthDateEstimated: json['birthDateEstimated'] as bool,
|
||||||
|
personality: json['personality'] as String?,
|
||||||
|
microchipNo: json['microchipNo'] as String?,
|
||||||
|
sterilizedOn: _dateOrNull(json['sterilizedOn']),
|
||||||
|
status: PetStatus.fromJson(json['status'] as String),
|
||||||
|
myRole: PetRole.fromJson(json['myRole'] as String),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
version: json['version'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final PetSpecies species;
|
||||||
|
final String? breedId;
|
||||||
|
final String? breedDisplayName;
|
||||||
|
final String? customBreedName;
|
||||||
|
final PetSex sex;
|
||||||
|
final DateTime? birthDate;
|
||||||
|
final bool birthDateEstimated;
|
||||||
|
final String? personality;
|
||||||
|
final String? microchipNo;
|
||||||
|
final DateTime? sterilizedOn;
|
||||||
|
final PetStatus status;
|
||||||
|
final PetRole myRole;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final int version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建宠物请求。breedId 与 customBreedName 二选一且互斥(400/40000 兜底在服务端)。
|
||||||
|
class CreatePetRequest {
|
||||||
|
const CreatePetRequest({
|
||||||
|
required this.name,
|
||||||
|
required this.species,
|
||||||
|
required this.sex,
|
||||||
|
this.breedId,
|
||||||
|
this.customBreedName,
|
||||||
|
this.birthDate,
|
||||||
|
this.birthDateEstimated,
|
||||||
|
this.personality,
|
||||||
|
this.microchipNo,
|
||||||
|
this.sterilizedOn,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final PetSpecies species;
|
||||||
|
final PetSex sex;
|
||||||
|
final String? breedId;
|
||||||
|
final String? customBreedName;
|
||||||
|
final DateTime? birthDate;
|
||||||
|
final bool? birthDateEstimated;
|
||||||
|
final String? personality;
|
||||||
|
final String? microchipNo;
|
||||||
|
final DateTime? sterilizedOn;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'name': name,
|
||||||
|
'species': species.name,
|
||||||
|
'sex': sex.name,
|
||||||
|
if (breedId != null) 'breedId': breedId,
|
||||||
|
if (customBreedName != null) 'customBreedName': customBreedName,
|
||||||
|
if (birthDate != null) 'birthDate': dateToJson(birthDate!),
|
||||||
|
if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated,
|
||||||
|
if (personality != null) 'personality': personality,
|
||||||
|
if (microchipNo != null) 'microchipNo': microchipNo,
|
||||||
|
if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新宠物请求(部分更新:缺席字段不变;不支持清空回 null;
|
||||||
|
/// 品种对整体替换;species 不可改;version 乐观锁必填)。
|
||||||
|
class UpdatePetRequest {
|
||||||
|
const UpdatePetRequest({
|
||||||
|
required this.version,
|
||||||
|
this.name,
|
||||||
|
this.breedId,
|
||||||
|
this.customBreedName,
|
||||||
|
this.sex,
|
||||||
|
this.birthDate,
|
||||||
|
this.birthDateEstimated,
|
||||||
|
this.personality,
|
||||||
|
this.microchipNo,
|
||||||
|
this.sterilizedOn,
|
||||||
|
this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int version;
|
||||||
|
final String? name;
|
||||||
|
final String? breedId;
|
||||||
|
final String? customBreedName;
|
||||||
|
final PetSex? sex;
|
||||||
|
final DateTime? birthDate;
|
||||||
|
final bool? birthDateEstimated;
|
||||||
|
final String? personality;
|
||||||
|
final String? microchipNo;
|
||||||
|
final DateTime? sterilizedOn;
|
||||||
|
final PetStatus? status;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
if (name != null) 'name': name,
|
||||||
|
if (breedId != null) 'breedId': breedId,
|
||||||
|
if (customBreedName != null) 'customBreedName': customBreedName,
|
||||||
|
if (sex != null) 'sex': sex!.name,
|
||||||
|
if (birthDate != null) 'birthDate': dateToJson(birthDate!),
|
||||||
|
if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated,
|
||||||
|
if (personality != null) 'personality': personality,
|
||||||
|
if (microchipNo != null) 'microchipNo': microchipNo,
|
||||||
|
if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!),
|
||||||
|
if (status != null) 'status': status!.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 品种目录项(只读字典)。
|
||||||
|
class Breed {
|
||||||
|
const Breed({
|
||||||
|
required this.id,
|
||||||
|
required this.species,
|
||||||
|
required this.code,
|
||||||
|
required this.displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Breed.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Breed(
|
||||||
|
id: json['id'] as String,
|
||||||
|
species: PetSpecies.fromJson(json['species'] as String),
|
||||||
|
code: json['code'] as String,
|
||||||
|
displayName: json['displayName'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final PetSpecies species;
|
||||||
|
final String code;
|
||||||
|
final String displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 体重记录(append-only,无乐观锁)。
|
||||||
|
class WeightRecord {
|
||||||
|
const WeightRecord({
|
||||||
|
required this.id,
|
||||||
|
required this.petId,
|
||||||
|
required this.weightKg,
|
||||||
|
required this.measuredAt,
|
||||||
|
required this.source,
|
||||||
|
required this.note,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WeightRecord.fromJson(Map<String, dynamic> json) {
|
||||||
|
return WeightRecord(
|
||||||
|
id: json['id'] as String,
|
||||||
|
petId: json['petId'] as String,
|
||||||
|
// 服务端 numeric(6,2),整数值也可能以 int 下发。
|
||||||
|
weightKg: (json['weightKg'] as num).toDouble(),
|
||||||
|
measuredAt: DateTime.parse(json['measuredAt'] as String),
|
||||||
|
source: WeightSource.fromJson(json['source'] as String),
|
||||||
|
note: json['note'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String petId;
|
||||||
|
final double weightKg;
|
||||||
|
final DateTime measuredAt;
|
||||||
|
final WeightSource source;
|
||||||
|
final String? note;
|
||||||
|
final DateTime createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加体重记录请求。
|
||||||
|
class CreateWeightRequest {
|
||||||
|
const CreateWeightRequest({
|
||||||
|
required this.weightKg,
|
||||||
|
required this.measuredAt,
|
||||||
|
this.source,
|
||||||
|
this.note,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double weightKg;
|
||||||
|
final DateTime measuredAt;
|
||||||
|
final WeightSource? source;
|
||||||
|
final String? note;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'weightKg': weightKg,
|
||||||
|
'measuredAt': measuredAt.toIso8601String(),
|
||||||
|
if (source != null) 'source': source!.name,
|
||||||
|
if (note != null) 'note': note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 疫苗目录项(只读字典)。
|
||||||
|
class VaccineCatalogItem {
|
||||||
|
const VaccineCatalogItem({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
required this.species,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory VaccineCatalogItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return VaccineCatalogItem(
|
||||||
|
id: json['id'] as String,
|
||||||
|
code: json['code'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
species: PetSpecies.fromJson(json['species'] as String),
|
||||||
|
description: json['description'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String code;
|
||||||
|
final String name;
|
||||||
|
final PetSpecies species;
|
||||||
|
final String? description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 疫苗记录。vaccineName 由目录解出(列表页免二次查字典)。
|
||||||
|
class Vaccination {
|
||||||
|
const Vaccination({
|
||||||
|
required this.id,
|
||||||
|
required this.petId,
|
||||||
|
required this.vaccineId,
|
||||||
|
required this.vaccineName,
|
||||||
|
required this.seriesKey,
|
||||||
|
required this.doseNo,
|
||||||
|
required this.doseLabel,
|
||||||
|
required this.status,
|
||||||
|
required this.plannedOn,
|
||||||
|
required this.administeredOn,
|
||||||
|
required this.nextDueOn,
|
||||||
|
required this.manufacturer,
|
||||||
|
required this.batchNo,
|
||||||
|
required this.notes,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Vaccination.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Vaccination(
|
||||||
|
id: json['id'] as String,
|
||||||
|
petId: json['petId'] as String,
|
||||||
|
vaccineId: json['vaccineId'] as String,
|
||||||
|
vaccineName: json['vaccineName'] as String,
|
||||||
|
seriesKey: json['seriesKey'] as String,
|
||||||
|
doseNo: json['doseNo'] as int,
|
||||||
|
doseLabel: json['doseLabel'] as String?,
|
||||||
|
status: VaccinationStatus.fromJson(json['status'] as String),
|
||||||
|
plannedOn: _dateOrNull(json['plannedOn']),
|
||||||
|
administeredOn: _dateOrNull(json['administeredOn']),
|
||||||
|
nextDueOn: _dateOrNull(json['nextDueOn']),
|
||||||
|
manufacturer: json['manufacturer'] as String?,
|
||||||
|
batchNo: json['batchNo'] as String?,
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
version: json['version'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String petId;
|
||||||
|
final String vaccineId;
|
||||||
|
final String vaccineName;
|
||||||
|
final String seriesKey;
|
||||||
|
final int doseNo;
|
||||||
|
final String? doseLabel;
|
||||||
|
final VaccinationStatus status;
|
||||||
|
final DateTime? plannedOn;
|
||||||
|
final DateTime? administeredOn;
|
||||||
|
final DateTime? nextDueOn;
|
||||||
|
final String? manufacturer;
|
||||||
|
final String? batchNo;
|
||||||
|
final String? notes;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final int version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建疫苗记录请求(创建状态仅 scheduled / completed)。
|
||||||
|
class CreateVaccinationRequest {
|
||||||
|
const CreateVaccinationRequest({
|
||||||
|
required this.vaccineId,
|
||||||
|
required this.seriesKey,
|
||||||
|
required this.doseNo,
|
||||||
|
required this.status,
|
||||||
|
this.doseLabel,
|
||||||
|
this.plannedOn,
|
||||||
|
this.administeredOn,
|
||||||
|
this.nextDueOn,
|
||||||
|
this.manufacturer,
|
||||||
|
this.batchNo,
|
||||||
|
this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String vaccineId;
|
||||||
|
final String seriesKey;
|
||||||
|
final int doseNo;
|
||||||
|
|
||||||
|
/// 契约允许 scheduled / completed(cancelled 创建无业务意义,服务端 400)。
|
||||||
|
final VaccinationStatus status;
|
||||||
|
final String? doseLabel;
|
||||||
|
final DateTime? plannedOn;
|
||||||
|
final DateTime? administeredOn;
|
||||||
|
final DateTime? nextDueOn;
|
||||||
|
final String? manufacturer;
|
||||||
|
final String? batchNo;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'vaccineId': vaccineId,
|
||||||
|
'seriesKey': seriesKey,
|
||||||
|
'doseNo': doseNo,
|
||||||
|
'status': status.name,
|
||||||
|
if (doseLabel != null) 'doseLabel': doseLabel,
|
||||||
|
if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!),
|
||||||
|
if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!),
|
||||||
|
if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!),
|
||||||
|
if (manufacturer != null) 'manufacturer': manufacturer,
|
||||||
|
if (batchNo != null) 'batchNo': batchNo,
|
||||||
|
if (notes != null) 'notes': notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新疫苗记录请求(vaccineId / seriesKey / doseNo 不可改;version 必填)。
|
||||||
|
class UpdateVaccinationRequest {
|
||||||
|
const UpdateVaccinationRequest({
|
||||||
|
required this.version,
|
||||||
|
this.status,
|
||||||
|
this.plannedOn,
|
||||||
|
this.administeredOn,
|
||||||
|
this.nextDueOn,
|
||||||
|
this.doseLabel,
|
||||||
|
this.manufacturer,
|
||||||
|
this.batchNo,
|
||||||
|
this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int version;
|
||||||
|
final VaccinationStatus? status;
|
||||||
|
final DateTime? plannedOn;
|
||||||
|
final DateTime? administeredOn;
|
||||||
|
final DateTime? nextDueOn;
|
||||||
|
final String? doseLabel;
|
||||||
|
final String? manufacturer;
|
||||||
|
final String? batchNo;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
if (status != null) 'status': status!.name,
|
||||||
|
if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!),
|
||||||
|
if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!),
|
||||||
|
if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!),
|
||||||
|
if (doseLabel != null) 'doseLabel': doseLabel,
|
||||||
|
if (manufacturer != null) 'manufacturer': manufacturer,
|
||||||
|
if (batchNo != null) 'batchNo': batchNo,
|
||||||
|
if (notes != null) 'notes': notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 健康事件(六类;金额整数分传输,元/分换算留给 UI 层)。
|
||||||
|
class HealthEvent {
|
||||||
|
const HealthEvent({
|
||||||
|
required this.id,
|
||||||
|
required this.petId,
|
||||||
|
required this.eventType,
|
||||||
|
required this.occurredAt,
|
||||||
|
required this.title,
|
||||||
|
required this.notes,
|
||||||
|
required this.amountCents,
|
||||||
|
required this.createdByUserId,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory HealthEvent.fromJson(Map<String, dynamic> json) {
|
||||||
|
return HealthEvent(
|
||||||
|
id: json['id'] as String,
|
||||||
|
petId: json['petId'] as String,
|
||||||
|
eventType: HealthEventType.fromJson(json['eventType'] as String),
|
||||||
|
occurredAt: DateTime.parse(json['occurredAt'] as String),
|
||||||
|
title: json['title'] as String,
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
amountCents: json['amountCents'] as int?,
|
||||||
|
createdByUserId: json['createdByUserId'] as String,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
version: json['version'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String petId;
|
||||||
|
final HealthEventType eventType;
|
||||||
|
final DateTime occurredAt;
|
||||||
|
final String title;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
/// 金额(整数分,非负,可空)。DTO 层保持整数分不换算。
|
||||||
|
final int? amountCents;
|
||||||
|
final String createdByUserId;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final int version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加健康事件请求(createdByUserId 取自 token,不收请求体)。
|
||||||
|
class CreateHealthEventRequest {
|
||||||
|
const CreateHealthEventRequest({
|
||||||
|
required this.eventType,
|
||||||
|
required this.occurredAt,
|
||||||
|
required this.title,
|
||||||
|
this.notes,
|
||||||
|
this.amountCents,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HealthEventType eventType;
|
||||||
|
final DateTime occurredAt;
|
||||||
|
final String title;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
/// 整数分;提交小数服务端 400/40000(不做静默截断)。
|
||||||
|
final int? amountCents;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'eventType': eventType.name,
|
||||||
|
'occurredAt': occurredAt.toIso8601String(),
|
||||||
|
'title': title,
|
||||||
|
if (notes != null) 'notes': notes,
|
||||||
|
if (amountCents != null) 'amountCents': amountCents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新健康事件请求(仅 title / notes / amountCents 可编辑;version 必填)。
|
||||||
|
class UpdateHealthEventRequest {
|
||||||
|
const UpdateHealthEventRequest({
|
||||||
|
required this.version,
|
||||||
|
this.title,
|
||||||
|
this.notes,
|
||||||
|
this.amountCents,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int version;
|
||||||
|
final String? title;
|
||||||
|
final String? notes;
|
||||||
|
final int? amountCents;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
if (title != null) 'title': title,
|
||||||
|
if (notes != null) 'notes': notes,
|
||||||
|
if (amountCents != null) 'amountCents': amountCents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 照护提醒(无 version 字段;completedAt 非空当且仅当 status=completed)。
|
||||||
|
class CareReminder {
|
||||||
|
const CareReminder({
|
||||||
|
required this.id,
|
||||||
|
required this.petId,
|
||||||
|
required this.reminderType,
|
||||||
|
required this.title,
|
||||||
|
required this.dueAt,
|
||||||
|
required this.status,
|
||||||
|
required this.completedAt,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CareReminder.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CareReminder(
|
||||||
|
id: json['id'] as String,
|
||||||
|
petId: json['petId'] as String,
|
||||||
|
reminderType: CareReminderType.fromJson(json['reminderType'] as String),
|
||||||
|
title: json['title'] as String,
|
||||||
|
dueAt: DateTime.parse(json['dueAt'] as String),
|
||||||
|
status: CareReminderStatus.fromJson(json['status'] as String),
|
||||||
|
completedAt: json['completedAt'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['completedAt'] as String),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String petId;
|
||||||
|
final CareReminderType reminderType;
|
||||||
|
final String title;
|
||||||
|
final DateTime dueAt;
|
||||||
|
final CareReminderStatus status;
|
||||||
|
final DateTime? completedAt;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建照护提醒请求(创建恒为 pending,不收 status)。
|
||||||
|
class CreateCareReminderRequest {
|
||||||
|
const CreateCareReminderRequest({
|
||||||
|
required this.reminderType,
|
||||||
|
required this.title,
|
||||||
|
required this.dueAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CareReminderType reminderType;
|
||||||
|
final String title;
|
||||||
|
final DateTime dueAt;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'reminderType': reminderType.name,
|
||||||
|
'title': title,
|
||||||
|
'dueAt': dueAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新提醒状态请求(状态流转专用:仅 status + completedAt)。
|
||||||
|
class UpdateCareReminderRequest {
|
||||||
|
const UpdateCareReminderRequest({required this.status, this.completedAt});
|
||||||
|
|
||||||
|
final CareReminderStatus status;
|
||||||
|
|
||||||
|
/// status=completed 时必填;其余状态禁带(违反 422/42202)。
|
||||||
|
/// 由客户端提交(而非服务端 now()),允许补记实际完成时刻。
|
||||||
|
final DateTime? completedAt;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'status': status.name,
|
||||||
|
if (completedAt != null) 'completedAt': completedAt!.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要:最新体重(无记录 → 整体 null)。
|
||||||
|
class SummaryLatestWeight {
|
||||||
|
const SummaryLatestWeight({required this.weightKg, required this.measuredAt});
|
||||||
|
|
||||||
|
factory SummaryLatestWeight.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SummaryLatestWeight(
|
||||||
|
weightKg: (json['weightKg'] as num).toDouble(),
|
||||||
|
measuredAt: DateTime.parse(json['measuredAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final double weightKg;
|
||||||
|
final DateTime measuredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要:疫苗进度(totalDoses=0 → 整体 null,不是 0/0)。
|
||||||
|
class SummaryVaccinationProgress {
|
||||||
|
const SummaryVaccinationProgress({
|
||||||
|
required this.completedDoses,
|
||||||
|
required this.totalDoses,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SummaryVaccinationProgress.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SummaryVaccinationProgress(
|
||||||
|
completedDoses: json['completedDoses'] as int,
|
||||||
|
totalDoses: json['totalDoses'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int completedDoses;
|
||||||
|
final int totalDoses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要:下次接种(候选集空 → 整体 null;dueOn 可为过去日期——逾期针仍是下一针)。
|
||||||
|
class SummaryNextVaccination {
|
||||||
|
const SummaryNextVaccination({
|
||||||
|
required this.vaccinationId,
|
||||||
|
required this.vaccineId,
|
||||||
|
required this.vaccineName,
|
||||||
|
required this.doseNo,
|
||||||
|
required this.doseLabel,
|
||||||
|
required this.dueOn,
|
||||||
|
required this.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SummaryNextVaccination.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SummaryNextVaccination(
|
||||||
|
vaccinationId: json['vaccinationId'] as String,
|
||||||
|
vaccineId: json['vaccineId'] as String,
|
||||||
|
vaccineName: json['vaccineName'] as String,
|
||||||
|
doseNo: json['doseNo'] as int,
|
||||||
|
doseLabel: json['doseLabel'] as String?,
|
||||||
|
dueOn: DateTime.parse(json['dueOn'] as String),
|
||||||
|
source: NextVaccinationSource.fromJson(json['source'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String vaccinationId;
|
||||||
|
final String vaccineId;
|
||||||
|
final String vaccineName;
|
||||||
|
final int doseNo;
|
||||||
|
final String? doseLabel;
|
||||||
|
final DateTime dueOn;
|
||||||
|
final NextVaccinationSource source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要:当月花费(恒非 null;无支出 amountCents=0;月边界随 tz 移动)。
|
||||||
|
class SummaryMonthlyExpense {
|
||||||
|
const SummaryMonthlyExpense({
|
||||||
|
required this.month,
|
||||||
|
required this.timezone,
|
||||||
|
required this.amountCents,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SummaryMonthlyExpense.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SummaryMonthlyExpense(
|
||||||
|
month: json['month'] as String,
|
||||||
|
timezone: json['timezone'] as String,
|
||||||
|
amountCents: json['amountCents'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO year-month(如 2026-09)。
|
||||||
|
final String month;
|
||||||
|
final String timezone;
|
||||||
|
final int amountCents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 档案聚合摘要(四项聚合实时计算;前三项无记录时为 null,
|
||||||
|
/// monthlyExpense 恒非 null——契约 PetSummary null 语义)。
|
||||||
|
class PetSummary {
|
||||||
|
const PetSummary({
|
||||||
|
required this.petId,
|
||||||
|
required this.latestWeight,
|
||||||
|
required this.vaccinationProgress,
|
||||||
|
required this.nextVaccination,
|
||||||
|
required this.monthlyExpense,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PetSummary.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PetSummary(
|
||||||
|
petId: json['petId'] as String,
|
||||||
|
latestWeight: json['latestWeight'] == null
|
||||||
|
? null
|
||||||
|
: SummaryLatestWeight.fromJson(
|
||||||
|
json['latestWeight'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
vaccinationProgress: json['vaccinationProgress'] == null
|
||||||
|
? null
|
||||||
|
: SummaryVaccinationProgress.fromJson(
|
||||||
|
json['vaccinationProgress'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
nextVaccination: json['nextVaccination'] == null
|
||||||
|
? null
|
||||||
|
: SummaryNextVaccination.fromJson(
|
||||||
|
json['nextVaccination'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
monthlyExpense: SummaryMonthlyExpense.fromJson(
|
||||||
|
json['monthlyExpense'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String petId;
|
||||||
|
final SummaryLatestWeight? latestWeight;
|
||||||
|
final SummaryVaccinationProgress? vaccinationProgress;
|
||||||
|
final SummaryNextVaccination? nextVaccination;
|
||||||
|
final SummaryMonthlyExpense monthlyExpense;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
/// 网络数据四态(第 9 节硬要求:loading / empty / error / retry,
|
||||||
|
/// data 态含空列表判定,页面据 [PetsLoadPhase] + 数据渲染四态)。
|
||||||
|
enum PetsLoadPhase { initial, loading, ready, error }
|
||||||
|
|
||||||
|
/// pets feature 状态控制器(开发计划 §4.2 分层:
|
||||||
|
/// Page/Widget → PetsController → PetsRepository → ApiClient)。
|
||||||
|
///
|
||||||
|
/// 宠物档案状态自此从 `AppState` 拆出:本 feature 不读写 AppState 的
|
||||||
|
/// 宠物/疫苗 demo 数据;服务端是唯一事实来源,内存副本仅作展示缓存
|
||||||
|
/// (无本地持久化——档案数据以 [refresh] 为失效/刷新策略)。
|
||||||
|
///
|
||||||
|
/// 本单只承载列表 + 详情主链路状态;体重/疫苗/事件/提醒的页面级
|
||||||
|
/// 状态由 T2-12~14 按页面直接经 Repository 取数或扩展本控制器。
|
||||||
|
class PetsController extends ChangeNotifier {
|
||||||
|
PetsController({required this._repository});
|
||||||
|
|
||||||
|
final PetsRepository _repository;
|
||||||
|
|
||||||
|
/// 记录级页面(体重/疫苗/事件/提醒)直接经仓库取数(22 号报告 §7
|
||||||
|
/// 交接约定:页面级状态按页自建,不膨胀本控制器)。
|
||||||
|
PetsRepository get repository => _repository;
|
||||||
|
|
||||||
|
PetsLoadPhase _phase = PetsLoadPhase.initial;
|
||||||
|
List<Pet> _pets = const [];
|
||||||
|
ApiException? _lastError;
|
||||||
|
bool _disposed = false;
|
||||||
|
final Map<PetSpecies, List<Breed>> _breedsCache = {};
|
||||||
|
|
||||||
|
PetsLoadPhase get phase => _phase;
|
||||||
|
|
||||||
|
/// 当前用户可见宠物(服务端 created_at DESC 排序原样保留)。
|
||||||
|
List<Pet> get pets => _pets;
|
||||||
|
|
||||||
|
/// ready 且列表为空 → 空态(新用户建档引导)。
|
||||||
|
bool get isEmpty => _phase == PetsLoadPhase.ready && _pets.isEmpty;
|
||||||
|
|
||||||
|
/// 最近一次加载失败的类型化错误(error 态时非 null)。
|
||||||
|
ApiException? get lastError => _lastError;
|
||||||
|
|
||||||
|
/// 加载 / 重试宠物列表。错误不外抛,收敛为 error 态供页面渲染 + retry。
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_phase = PetsLoadPhase.loading;
|
||||||
|
_lastError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
_pets = await _repository.listPets();
|
||||||
|
_phase = PetsLoadPhase.ready;
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
_lastError = error;
|
||||||
|
_phase = PetsLoadPhase.error;
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建宠物:成功后就地插入列表头(服务端 created_at DESC,新建最前),
|
||||||
|
/// 失败按类型化异常外抛给表单层处理(40903 芯片号冲突等)。
|
||||||
|
Future<Pet> createPet(CreatePetRequest request) async {
|
||||||
|
final pet = await _repository.createPet(request);
|
||||||
|
_pets = [pet, ..._pets];
|
||||||
|
if (_phase != PetsLoadPhase.ready) _phase = PetsLoadPhase.ready;
|
||||||
|
_notify();
|
||||||
|
return pet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取单只宠物详情并同步列表内存副本。
|
||||||
|
Future<Pet> getPet(String petId) async {
|
||||||
|
final pet = await _repository.getPet(petId);
|
||||||
|
_replaceInList(pet);
|
||||||
|
return pet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新宠物档案:成功后同步列表副本;40902 版本冲突等外抛,
|
||||||
|
/// 页面提示后应调用 [getPet] 刷新取新 version 重提。
|
||||||
|
Future<Pet> updatePet(String petId, UpdatePetRequest request) async {
|
||||||
|
final pet = await _repository.updatePet(petId, request);
|
||||||
|
_replaceInList(pet);
|
||||||
|
return pet;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _replaceInList(Pet pet) {
|
||||||
|
final index = _pets.indexWhere((item) => item.id == pet.id);
|
||||||
|
if (index == -1) return;
|
||||||
|
_pets = [..._pets]..[index] = pet;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 品种目录(只读字典):按物种缓存,会话内目录不变;
|
||||||
|
/// 失败按类型化异常外抛,供表单层内联重试。
|
||||||
|
Future<List<Breed>> loadBreeds(PetSpecies species) async {
|
||||||
|
final cached = _breedsCache[species];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
final breeds = await _repository.listBreeds(species: species);
|
||||||
|
_breedsCache[species] = breeds;
|
||||||
|
return breeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登出清空:回 initial 态并清缓存,避免上一账号档案跨会话泄漏;
|
||||||
|
/// 重新登录后主壳重建,列表页 initState 重新触发 [refresh]。
|
||||||
|
void reset() {
|
||||||
|
_phase = PetsLoadPhase.initial;
|
||||||
|
_pets = const [];
|
||||||
|
_lastError = null;
|
||||||
|
_breedsCache.clear();
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
+261
-676
@@ -1,288 +1,224 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
import 'package:patbond_flutter/data/demo_data.dart';
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
import 'package:patbond_flutter/models/models.dart';
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
import 'package:patbond_flutter/state/app_state.dart';
|
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
import 'package:patbond_flutter/widgets/common.dart';
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
class PetsPage extends StatelessWidget {
|
/// 档案 Tab 落地页:宠物列表(T2-12 / 05 号规范 §4.1 P1)。
|
||||||
const PetsPage({required this.appState, super.key});
|
///
|
||||||
|
/// 真实数据经 [PetsController] 四态驱动:loading(居中转圈)、
|
||||||
|
/// empty(空态插画 + 建档 CTA)、error(横幅 + 重试)、ready(列表)。
|
||||||
|
/// 页面不直连 ApiClient,不读写 AppState demo 数据。
|
||||||
|
class PetsPage extends StatefulWidget {
|
||||||
|
const PetsPage({
|
||||||
|
required this.controller,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.healthAnalytics,
|
||||||
|
});
|
||||||
|
|
||||||
final AppState appState;
|
final PetsController controller;
|
||||||
|
final PetAnalytics? analytics;
|
||||||
|
|
||||||
String ageLabel(String value) {
|
/// health_record 域埋点(T2-13,记录页面族透传)。
|
||||||
final birthday = DateTime.tryParse(value);
|
final HealthRecordAnalytics? healthAnalytics;
|
||||||
if (birthday == null) return '年龄未知';
|
|
||||||
final today = DateTime.now();
|
@override
|
||||||
var age = today.year - birthday.year;
|
State<PetsPage> createState() => _PetsPageState();
|
||||||
if (today.month < birthday.month ||
|
}
|
||||||
(today.month == birthday.month && today.day < birthday.day)) {
|
|
||||||
age--;
|
class _PetsPageState extends State<PetsPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// 主壳挂载即预取(IndexedStack 各 Tab 同时构建);重登后控制器
|
||||||
|
// 已被 reset 回 initial,会重新拉取。
|
||||||
|
if (widget.controller.phase == PetsLoadPhase.initial) {
|
||||||
|
widget.controller.refresh();
|
||||||
}
|
}
|
||||||
return '${age < 0 ? 0 : age} 岁';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> editPet(BuildContext context) async {
|
Future<void> _openCreate(PetCreateEntryPoint entryPoint) async {
|
||||||
final value = await showModalBottomSheet<PetProfile>(
|
final created = await Navigator.of(context).push<Pet>(
|
||||||
context: context,
|
fadePageRoute(
|
||||||
isScrollControlled: true,
|
PetFormPage.create(
|
||||||
useSafeArea: true,
|
controller: widget.controller,
|
||||||
builder: (context) => EditPetSheet(pet: appState.pet),
|
analytics: widget.analytics,
|
||||||
|
entryPoint: entryPoint,
|
||||||
|
),
|
||||||
|
// 建宠表单曝光由既有 RouteObserver 采集(06 §1.6 pet_form)。
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.petForm.pageName),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (value != null) await appState.updatePet(value);
|
if (created != null && mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('已为「${created.name}」建立档案 🐾')));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> editVaccines(BuildContext context) async {
|
void _openDetail(Pet pet) {
|
||||||
final value = await showModalBottomSheet<VaccineRecord>(
|
Navigator.of(context).push(
|
||||||
context: context,
|
fadePageRoute(
|
||||||
isScrollControlled: true,
|
PetDetailPage(
|
||||||
useSafeArea: true,
|
controller: widget.controller,
|
||||||
builder: (context) => VaccineSheet(record: appState.vaccines),
|
petId: pet.id,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
healthAnalytics: widget.healthAnalytics,
|
||||||
|
),
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (value != null) await appState.updateVaccines(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pet = appState.pet;
|
return ListenableBuilder(
|
||||||
final vaccines = appState.vaccines;
|
listenable: widget.controller,
|
||||||
final progress = vaccines.totalDoses == 0
|
builder: (context, _) {
|
||||||
? 0.0
|
final controller = widget.controller;
|
||||||
: vaccines.completedDoses / vaccines.totalDoses;
|
switch (controller.phase) {
|
||||||
|
case PetsLoadPhase.initial:
|
||||||
|
case PetsLoadPhase.loading:
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
case PetsLoadPhase.error:
|
||||||
|
return _LoadErrorView(
|
||||||
|
message: petLoadErrorMessage(controller.lastError),
|
||||||
|
onRetry: controller.refresh,
|
||||||
|
);
|
||||||
|
case PetsLoadPhase.ready:
|
||||||
|
if (controller.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: EmptyStateIllustration(
|
||||||
|
icon: Icons.pets,
|
||||||
|
title: '还没有宠物档案',
|
||||||
|
description: '添加毛孩子,开始记录 TA 的健康点滴',
|
||||||
|
ctaLabel: '添加宠物',
|
||||||
|
onCtaPressed: () =>
|
||||||
|
_openCreate(PetCreateEntryPoint.profileEmptyState),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _petList(controller.pets);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return ListView(
|
Widget _petList(List<Pet> pets) {
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
|
return RefreshIndicator(
|
||||||
children: [
|
onRefresh: widget.controller.refresh,
|
||||||
Column(
|
child: ListView(
|
||||||
children: [
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
|
||||||
Stack(
|
children: [
|
||||||
children: [
|
Row(
|
||||||
InkWell(
|
children: [
|
||||||
onTap: () => editPet(context),
|
Text('我的宠物', style: Theme.of(context).textTheme.titleLarge),
|
||||||
borderRadius: BorderRadius.circular(54),
|
const Spacer(),
|
||||||
child: RemoteImage(
|
TextButton.icon(
|
||||||
url: pet.avatarUrl,
|
onPressed: () => _openCreate(PetCreateEntryPoint.petList),
|
||||||
width: 104,
|
icon: const Icon(Icons.add, size: 18),
|
||||||
height: 104,
|
label: const Text('添加'),
|
||||||
borderRadius: BorderRadius.circular(52),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
Positioned(
|
const SizedBox(height: 12),
|
||||||
right: 0,
|
for (final pet in pets) ...[
|
||||||
bottom: 0,
|
_PetCard(pet: pet, onTap: () => _openDetail(pet)),
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 16,
|
|
||||||
backgroundColor: AppColors.primary,
|
|
||||||
child: IconButton(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
tooltip: '编辑资料',
|
|
||||||
onPressed: () => editPet(context),
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.edit,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${pet.breed} · ${pet.gender == PetGender.male ? '男孩' : '女孩'} · ${ageLabel(pet.birthday)} · 活泼',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => editPet(context),
|
|
||||||
icon: const Icon(Icons.settings_outlined, size: 17),
|
|
||||||
label: const Text('编辑资料'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
_AddPetCard(onTap: () => _openCreate(PetCreateEntryPoint.petList)),
|
||||||
const SizedBox(height: 24),
|
],
|
||||||
Row(
|
),
|
||||||
children: [
|
|
||||||
Text('宠物数据', style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
const Spacer(),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => editPet(context),
|
|
||||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
|
||||||
label: const Text('编辑'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _StatCard(
|
|
||||||
icon: Icons.monitor_weight_outlined,
|
|
||||||
color: AppColors.primary,
|
|
||||||
value: '${pet.weight.toStringAsFixed(1)}kg',
|
|
||||||
label: '体重',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: _StatCard(
|
|
||||||
icon: Icons.vaccines_outlined,
|
|
||||||
color: AppColors.success,
|
|
||||||
value: '${vaccines.completedDoses}/${vaccines.totalDoses}',
|
|
||||||
label: '疫苗进度',
|
|
||||||
onTap: () => editVaccines(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
const Expanded(
|
|
||||||
child: _StatCard(
|
|
||||||
icon: Icons.payments_outlined,
|
|
||||||
color: AppColors.warning,
|
|
||||||
value: '¥328',
|
|
||||||
label: '本月花费',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
SectionCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text('健康概览', style: Theme.of(context).textTheme.titleMedium),
|
|
||||||
const Spacer(),
|
|
||||||
Text('更新于今日', style: Theme.of(context).textTheme.bodySmall),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
SizedBox.square(
|
|
||||||
dimension: 62,
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(
|
|
||||||
value: progress,
|
|
||||||
strokeWidth: 7,
|
|
||||||
backgroundColor: AppColors.border,
|
|
||||||
color: AppColors.success,
|
|
||||||
),
|
|
||||||
Center(
|
|
||||||
child: Text(
|
|
||||||
'${(progress * 100).round()}%',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.success,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
progress >= 1 ? '疫苗接种完成' : '疫苗接种进行中',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 5),
|
|
||||||
Text(
|
|
||||||
'下一针:${vaccines.reminderVaccine}\n预计 ${vaccines.reminderDate}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: '管理疫苗',
|
|
||||||
onPressed: () => editVaccines(context),
|
|
||||||
icon: const Icon(Icons.edit_outlined),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(18),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.successSurface,
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
border: Border.all(color: AppColors.success.withAlpha(140)),
|
|
||||||
),
|
|
||||||
child: const Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.auto_awesome, color: AppColors.success),
|
|
||||||
SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'健康提醒:已经半年没有进行体内外驱虫,建议本周安排一次。',
|
|
||||||
style: TextStyle(color: AppColors.successInk, height: 1.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 22),
|
|
||||||
Text('成长足迹', style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
const _TimelineTile(
|
|
||||||
icon: Icons.medical_services_outlined,
|
|
||||||
title: '医疗 · 狂犬疫苗接种',
|
|
||||||
subtitle: '2025-06-12 · 瑞派宠物医院',
|
|
||||||
status: '已完成',
|
|
||||||
),
|
|
||||||
const _TimelineTile(
|
|
||||||
icon: Icons.restaurant_outlined,
|
|
||||||
title: '喂养 · 更换幼犬粮',
|
|
||||||
subtitle: '2025-05-20 · 体重增长稳定',
|
|
||||||
status: '已记录',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatCard extends StatelessWidget {
|
/// 加载失败态:InlineErrorBanner + 重试按钮(05 §4.2 错误三层模型)。
|
||||||
const _StatCard({
|
class _LoadErrorView extends StatelessWidget {
|
||||||
required this.icon,
|
const _LoadErrorView({required this.message, required this.onRetry});
|
||||||
required this.color,
|
|
||||||
required this.value,
|
|
||||||
required this.label,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
final String message;
|
||||||
final Color color;
|
final Future<void> Function() onRetry;
|
||||||
final String value;
|
|
||||||
final String label;
|
@override
|
||||||
final VoidCallback? onTap;
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
InlineErrorBanner(message: message),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(onPressed: onRetry, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宠物卡(05 §4.1):头像 lg + 名字 + 元信息 + chevron;非 active
|
||||||
|
/// 状态以 TagPill 标示(图文双通道由状态文案承担)。
|
||||||
|
class _PetCard extends StatelessWidget {
|
||||||
|
const _PetCard({required this.pet, required this.onTap});
|
||||||
|
|
||||||
|
final Pet pet;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
padding: const EdgeInsets.all(14),
|
||||||
child: Column(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: color),
|
const PetAvatar(size: PetAvatarSize.lg),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(width: 12),
|
||||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w800)),
|
Expanded(
|
||||||
const SizedBox(height: 2),
|
child: Column(
|
||||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
pet.name,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
petMetaLine(pet),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (pet.status != PetStatus.active)
|
||||||
|
TagPill(
|
||||||
|
petStatusLabel(pet.status),
|
||||||
|
color: pet.status == PetStatus.lost
|
||||||
|
? AppColors.error
|
||||||
|
: AppColors.muted,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Icon(Icons.chevron_right, color: AppColors.muted),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -291,433 +227,82 @@ class _StatCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TimelineTile extends StatelessWidget {
|
/// 虚线「添加宠物」卡(05 §4.1:border 色 1.5px dashed,radius 24,高 64)。
|
||||||
const _TimelineTile({
|
class _AddPetCard extends StatelessWidget {
|
||||||
required this.icon,
|
const _AddPetCard({required this.onTap});
|
||||||
required this.title,
|
|
||||||
required this.subtitle,
|
final VoidCallback onTap;
|
||||||
required this.status,
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _DashedBorderPainter(
|
||||||
|
color: AppColors.border,
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
radius: AppRadius.xl,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 64,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'+ 添加宠物',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.primaryStrong,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DashedBorderPainter extends CustomPainter {
|
||||||
|
const _DashedBorderPainter({
|
||||||
|
required this.color,
|
||||||
|
required this.strokeWidth,
|
||||||
|
required this.radius,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final Color color;
|
||||||
final String title;
|
final double strokeWidth;
|
||||||
final String subtitle;
|
final double radius;
|
||||||
final String status;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
void paint(Canvas canvas, Size size) {
|
||||||
return Padding(
|
final paint = Paint()
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
..color = color
|
||||||
child: SectionCard(
|
..strokeWidth = strokeWidth
|
||||||
child: Row(
|
..style = PaintingStyle.stroke;
|
||||||
children: [
|
final path = Path()
|
||||||
CircleAvatar(child: Icon(icon, color: AppColors.primary, size: 20)),
|
..addRRect(
|
||||||
const SizedBox(width: 12),
|
RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)),
|
||||||
Expanded(
|
);
|
||||||
child: Column(
|
const dashWidth = 6.0;
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const dashGap = 4.0;
|
||||||
children: [
|
for (final metric in path.computeMetrics()) {
|
||||||
Text(
|
var distance = 0.0;
|
||||||
title,
|
while (distance < metric.length) {
|
||||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
canvas.drawPath(
|
||||||
),
|
metric.extractPath(distance, distance + dashWidth),
|
||||||
const SizedBox(height: 3),
|
paint,
|
||||||
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
|
);
|
||||||
],
|
distance += dashWidth + dashGap;
|
||||||
),
|
}
|
||||||
),
|
}
|
||||||
TagPill(status, color: AppColors.success),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class EditPetSheet extends StatefulWidget {
|
|
||||||
const EditPetSheet({required this.pet, super.key});
|
|
||||||
|
|
||||||
final PetProfile pet;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<EditPetSheet> createState() => _EditPetSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EditPetSheetState extends State<EditPetSheet> {
|
|
||||||
late final TextEditingController nameController;
|
|
||||||
late final TextEditingController weightController;
|
|
||||||
late String breed;
|
|
||||||
late PetGender gender;
|
|
||||||
late DateTime birthday;
|
|
||||||
late String avatar;
|
|
||||||
|
|
||||||
static const breeds = ['柴犬', '金毛寻回犬', '柯基', '哈士奇', '英国短毛猫', '其他'];
|
|
||||||
static const avatars = [
|
|
||||||
petAvatar,
|
|
||||||
'https://images.unsplash.com/photo-1517849845537-4d257902454a?auto=format&fit=crop&w=600&q=85',
|
|
||||||
'https://images.unsplash.com/photo-1543466835-00a7907e9de1?auto=format&fit=crop&w=600&q=85',
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
nameController = TextEditingController(text: widget.pet.name);
|
|
||||||
weightController = TextEditingController(text: '${widget.pet.weight}');
|
|
||||||
breed = breeds.contains(widget.pet.breed) ? widget.pet.breed : '其他';
|
|
||||||
gender = widget.pet.gender;
|
|
||||||
birthday = DateTime.tryParse(widget.pet.birthday) ?? DateTime(2024, 5, 15);
|
|
||||||
avatar = widget.pet.avatarUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
nameController.dispose();
|
|
||||||
weightController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
String dateText(DateTime value) {
|
|
||||||
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
20,
|
|
||||||
10,
|
|
||||||
20,
|
|
||||||
MediaQuery.viewInsetsOf(context).bottom + 20,
|
|
||||||
),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const _SheetHandle(),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text('编辑宠物资料', style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
Center(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
final index = avatars.indexOf(avatar);
|
|
||||||
setState(
|
|
||||||
() => avatar = avatars[(index + 1) % avatars.length],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(48),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
RemoteImage(
|
|
||||||
url: avatar,
|
|
||||||
width: 96,
|
|
||||||
height: 96,
|
|
||||||
borderRadius: BorderRadius.circular(48),
|
|
||||||
),
|
|
||||||
const Positioned(
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 15,
|
|
||||||
child: Icon(Icons.edit, size: 15),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
TextField(
|
|
||||||
controller: nameController,
|
|
||||||
decoration: const InputDecoration(labelText: '宠物昵称'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
DropdownButtonFormField<String>(
|
|
||||||
initialValue: breed,
|
|
||||||
decoration: const InputDecoration(labelText: '品种'),
|
|
||||||
items: breeds
|
|
||||||
.map(
|
|
||||||
(value) =>
|
|
||||||
DropdownMenuItem(value: value, child: Text(value)),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) setState(() => breed = value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SegmentedButton<PetGender>(
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(
|
|
||||||
value: PetGender.male,
|
|
||||||
icon: Icon(Icons.male),
|
|
||||||
label: Text('男孩'),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: PetGender.female,
|
|
||||||
icon: Icon(Icons.female),
|
|
||||||
label: Text('女孩'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {gender},
|
|
||||||
onSelectionChanged: (value) =>
|
|
||||||
setState(() => gender = value.first),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
ListTile(
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
side: const BorderSide(color: AppColors.border),
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
),
|
|
||||||
leading: const Icon(Icons.cake_outlined),
|
|
||||||
title: const Text('生日'),
|
|
||||||
subtitle: Text(dateText(birthday)),
|
|
||||||
trailing: const Icon(Icons.calendar_month_outlined),
|
|
||||||
onTap: () async {
|
|
||||||
final value = await showDatePicker(
|
|
||||||
context: context,
|
|
||||||
initialDate: birthday,
|
|
||||||
firstDate: DateTime(2000),
|
|
||||||
lastDate: DateTime.now(),
|
|
||||||
);
|
|
||||||
if (value != null) setState(() => birthday = value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: weightController,
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
|
||||||
decimal: true,
|
|
||||||
),
|
|
||||||
decoration: const InputDecoration(labelText: '体重(kg)'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
FilledButton.icon(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(50),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
final name = nameController.text.trim();
|
|
||||||
final weight = double.tryParse(weightController.text);
|
|
||||||
if (name.isEmpty || weight == null || weight <= 0) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('请填写有效的昵称和体重')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Navigator.pop(
|
|
||||||
context,
|
|
||||||
widget.pet.copyWith(
|
|
||||||
name: name,
|
|
||||||
breed: breed,
|
|
||||||
gender: gender,
|
|
||||||
birthday: dateText(birthday),
|
|
||||||
weight: weight,
|
|
||||||
avatarUrl: avatar,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.save_outlined),
|
|
||||||
label: const Text('保存修改'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class VaccineSheet extends StatefulWidget {
|
|
||||||
const VaccineSheet({required this.record, super.key});
|
|
||||||
|
|
||||||
final VaccineRecord record;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<VaccineSheet> createState() => _VaccineSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VaccineSheetState extends State<VaccineSheet> {
|
|
||||||
late List<VaccineItem> items;
|
|
||||||
late int totalDoses;
|
|
||||||
late final TextEditingController reminderController;
|
|
||||||
late final TextEditingController dateController;
|
|
||||||
|
|
||||||
int get completed =>
|
|
||||||
items.where((item) => item.status == VaccineStatus.completed).length;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
items = List<VaccineItem>.from(widget.record.items);
|
|
||||||
totalDoses = widget.record.totalDoses;
|
|
||||||
reminderController = TextEditingController(
|
|
||||||
text: widget.record.reminderVaccine,
|
|
||||||
);
|
|
||||||
dateController = TextEditingController(text: widget.record.reminderDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
reminderController.dispose();
|
|
||||||
dateController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void changeTotal(int delta) {
|
|
||||||
final minimum = completed > items.length ? completed : items.length;
|
|
||||||
setState(() => totalDoses = (totalDoses + delta).clamp(minimum, 20));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final progress = totalDoses == 0 ? 0.0 : completed / totalDoses;
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
20,
|
|
||||||
10,
|
|
||||||
20,
|
|
||||||
MediaQuery.viewInsetsOf(context).bottom + 20,
|
|
||||||
),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const _SheetHandle(),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const CircleAvatar(child: Icon(Icons.vaccines_outlined)),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text('疫苗接种管理', style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SectionCard(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Text('已接种 / 总规划'),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton.filledTonal(
|
|
||||||
onPressed: () => changeTotal(-1),
|
|
||||||
icon: const Icon(Icons.remove),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Text(
|
|
||||||
'$completed / $totalDoses',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton.filled(
|
|
||||||
onPressed: () => changeTotal(1),
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
LinearProgressIndicator(value: progress, minHeight: 9),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text('接种详情', style: Theme.of(context).textTheme.titleMedium),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
...items.asMap().entries.map((entry) {
|
|
||||||
final index = entry.key;
|
|
||||||
final item = entry.value;
|
|
||||||
final done = item.status == VaccineStatus.completed;
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: CheckboxListTile(
|
|
||||||
value: done,
|
|
||||||
title: Text(item.name),
|
|
||||||
subtitle: Text(
|
|
||||||
done ? '接种时间:${item.date}' : '计划接种:${item.date}',
|
|
||||||
),
|
|
||||||
secondary: Icon(
|
|
||||||
done ? Icons.check_circle : Icons.radio_button_unchecked,
|
|
||||||
color: done ? AppColors.success : AppColors.muted,
|
|
||||||
),
|
|
||||||
onChanged: (_) {
|
|
||||||
final today = DateTime.now();
|
|
||||||
final date =
|
|
||||||
'${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
|
|
||||||
setState(() {
|
|
||||||
items[index] = item.copyWith(
|
|
||||||
status: done
|
|
||||||
? VaccineStatus.pending
|
|
||||||
: VaccineStatus.completed,
|
|
||||||
date: done ? '待定' : date,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
TextField(
|
|
||||||
controller: reminderController,
|
|
||||||
decoration: const InputDecoration(labelText: '下一针提醒'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: dateController,
|
|
||||||
decoration: const InputDecoration(labelText: '预计日期(YYYY-MM-DD)'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
FilledButton.icon(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(50),
|
|
||||||
),
|
|
||||||
onPressed: () => Navigator.pop(
|
|
||||||
context,
|
|
||||||
VaccineRecord(
|
|
||||||
completedDoses: completed,
|
|
||||||
totalDoses: totalDoses,
|
|
||||||
items: items,
|
|
||||||
reminderVaccine: reminderController.text.trim(),
|
|
||||||
reminderDate: dateController.text.trim(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.save_outlined),
|
|
||||||
label: const Text('保存疫苗记录'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SheetHandle extends StatelessWidget {
|
|
||||||
const _SheetHandle();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Center(
|
|
||||||
child: Container(
|
|
||||||
width: 44,
|
|
||||||
height: 5,
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.border,
|
|
||||||
borderRadius: BorderRadius.circular(99),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_DashedBorderPainter oldDelegate) =>
|
||||||
|
color != oldDelegate.color ||
|
||||||
|
strokeWidth != oldDelegate.strokeWidth ||
|
||||||
|
radius != oldDelegate.radius;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// pets 域仓库接口(契约 v1.2.0 的 12 路径 / 18 操作全覆盖;
|
||||||
|
/// 页面依赖此抽象,widget 测试注入假实现)。
|
||||||
|
abstract class PetsRepository {
|
||||||
|
// ---- 宠物 CRUD ----
|
||||||
|
Future<List<Pet>> listPets();
|
||||||
|
Future<Pet> createPet(CreatePetRequest request);
|
||||||
|
Future<Pet> getPet(String petId);
|
||||||
|
Future<Pet> updatePet(String petId, UpdatePetRequest request);
|
||||||
|
|
||||||
|
// ---- 字典 ----
|
||||||
|
Future<List<Breed>> listBreeds({PetSpecies? species});
|
||||||
|
Future<List<VaccineCatalogItem>> listVaccineCatalog({PetSpecies? species});
|
||||||
|
|
||||||
|
// ---- 体重(cursor 分页)----
|
||||||
|
Future<CursorPage<WeightRecord>> listWeights(
|
||||||
|
String petId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
});
|
||||||
|
Future<WeightRecord> createWeight(String petId, CreateWeightRequest request);
|
||||||
|
|
||||||
|
// ---- 疫苗 ----
|
||||||
|
Future<List<Vaccination>> listVaccinations(String petId);
|
||||||
|
Future<Vaccination> createVaccination(
|
||||||
|
String petId,
|
||||||
|
CreateVaccinationRequest request,
|
||||||
|
);
|
||||||
|
Future<Vaccination> updateVaccination(
|
||||||
|
String vaccinationId,
|
||||||
|
UpdateVaccinationRequest request,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 健康事件(cursor 分页)----
|
||||||
|
Future<CursorPage<HealthEvent>> listHealthEvents(
|
||||||
|
String petId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
});
|
||||||
|
Future<HealthEvent> createHealthEvent(
|
||||||
|
String petId,
|
||||||
|
CreateHealthEventRequest request,
|
||||||
|
);
|
||||||
|
Future<HealthEvent> updateHealthEvent(
|
||||||
|
String eventId,
|
||||||
|
UpdateHealthEventRequest request,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 照护提醒 ----
|
||||||
|
Future<List<CareReminder>> listCareReminders(
|
||||||
|
String petId, {
|
||||||
|
CareReminderStatus? status,
|
||||||
|
});
|
||||||
|
Future<CareReminder> createCareReminder(
|
||||||
|
String petId,
|
||||||
|
CreateCareReminderRequest request,
|
||||||
|
);
|
||||||
|
Future<CareReminder> updateCareReminder(
|
||||||
|
String reminderId,
|
||||||
|
UpdateCareReminderRequest request,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 聚合摘要 ----
|
||||||
|
Future<PetSummary> getPetSummary(String petId, {String? tz});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
|
||||||
|
/// 拦截 + 401/40101 单飞刷新重放);pets 域业务错误码升格为类型化异常。
|
||||||
|
///
|
||||||
|
/// 幂等:weights / vaccinations / health-events / care-reminders 四个 POST
|
||||||
|
/// 按契约携带可选 `Idempotency-Key`(每次逻辑提交换新键;token 刷新后的
|
||||||
|
/// 自动重放沿用同一个键,与 auth register 先例一致)。
|
||||||
|
class ApiPetsRepository implements PetsRepository {
|
||||||
|
ApiPetsRepository({required this._api, this._uuid = const Uuid()});
|
||||||
|
|
||||||
|
final ApiClient _api;
|
||||||
|
final Uuid _uuid;
|
||||||
|
|
||||||
|
Future<Object?> _request(
|
||||||
|
String path, {
|
||||||
|
String method = 'GET',
|
||||||
|
Object? body,
|
||||||
|
Map<String, Object?>? query,
|
||||||
|
bool idempotent = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _api.request(
|
||||||
|
path,
|
||||||
|
method: method,
|
||||||
|
body: body,
|
||||||
|
query: query,
|
||||||
|
headers: idempotent ? {'Idempotency-Key': _uuid.v4()} : null,
|
||||||
|
requiresAuth: true,
|
||||||
|
);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
throw mapPetBusinessException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _asMap(Object? data) => data! as Map<String, dynamic>;
|
||||||
|
|
||||||
|
List<T> _asList<T>(Object? data, T Function(Map<String, dynamic>) fromJson) {
|
||||||
|
return (data! as List)
|
||||||
|
.map((item) => fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Pet>> listPets() async {
|
||||||
|
final data = await _request('/api/v1/pets');
|
||||||
|
return _asList(data, Pet.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> createPet(CreatePetRequest request) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return Pet.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> getPet(String petId) async {
|
||||||
|
final data = await _request('/api/v1/pets/$petId');
|
||||||
|
return Pet.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> updatePet(String petId, UpdatePetRequest request) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return Pet.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Breed>> listBreeds({PetSpecies? species}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/breeds',
|
||||||
|
query: {if (species != null) 'species': species.name},
|
||||||
|
);
|
||||||
|
return _asList(data, Breed.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<VaccineCatalogItem>> listVaccineCatalog({
|
||||||
|
PetSpecies? species,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/vaccine-catalog',
|
||||||
|
query: {if (species != null) 'species': species.name},
|
||||||
|
);
|
||||||
|
return _asList(data, VaccineCatalogItem.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<WeightRecord>> listWeights(
|
||||||
|
String petId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/weights',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), WeightRecord.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WeightRecord> createWeight(
|
||||||
|
String petId,
|
||||||
|
CreateWeightRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/weights',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return WeightRecord.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Vaccination>> listVaccinations(String petId) async {
|
||||||
|
final data = await _request('/api/v1/pets/$petId/vaccinations');
|
||||||
|
return _asList(data, Vaccination.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Vaccination> createVaccination(
|
||||||
|
String petId,
|
||||||
|
CreateVaccinationRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/vaccinations',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return Vaccination.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Vaccination> updateVaccination(
|
||||||
|
String vaccinationId,
|
||||||
|
UpdateVaccinationRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/vaccinations/$vaccinationId',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return Vaccination.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<HealthEvent>> listHealthEvents(
|
||||||
|
String petId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/health-events',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), HealthEvent.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthEvent> createHealthEvent(
|
||||||
|
String petId,
|
||||||
|
CreateHealthEventRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/health-events',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return HealthEvent.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthEvent> updateHealthEvent(
|
||||||
|
String eventId,
|
||||||
|
UpdateHealthEventRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/health-events/$eventId',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return HealthEvent.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CareReminder>> listCareReminders(
|
||||||
|
String petId, {
|
||||||
|
CareReminderStatus? status,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/care-reminders',
|
||||||
|
query: {if (status != null) 'status': status.name},
|
||||||
|
);
|
||||||
|
return _asList(data, CareReminder.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CareReminder> createCareReminder(
|
||||||
|
String petId,
|
||||||
|
CreateCareReminderRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/care-reminders',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return CareReminder.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CareReminder> updateCareReminder(
|
||||||
|
String reminderId,
|
||||||
|
UpdateCareReminderRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/care-reminders/$reminderId',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return CareReminder.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PetSummary> getPetSummary(String petId, {String? tz}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/summary',
|
||||||
|
query: {'tz': ?tz},
|
||||||
|
);
|
||||||
|
return PetSummary.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
/// 疫苗登记表单页(T2-13)。
|
||||||
|
///
|
||||||
|
/// - 目录:vaccine-catalog 按宠物物种过滤(契约:疫苗 species 须与宠物
|
||||||
|
/// 一致),目录网络面自带 loading / 失败重试 / 空目录提示。
|
||||||
|
/// - 系列/剂次:seriesKey 选中疫苗后以目录 code 自动预填(可改);
|
||||||
|
/// doseNo 默认 1。
|
||||||
|
/// - 状态机:创建仅 scheduled / completed;状态-日期规则由
|
||||||
|
/// [vaccinationDateRuleError] 前端拦截(scheduled 态不渲染接种日期字段,
|
||||||
|
/// 结构上杜绝「scheduled 带 administeredOn」),后端 42201 兜底提示;
|
||||||
|
/// 同系列同剂次重复登记 40904 兜底提示。
|
||||||
|
/// - 埋点:health_record_create_started/succeeded/failed(recordType=vaccine)。
|
||||||
|
class VaccinationFormPage extends StatefulWidget {
|
||||||
|
const VaccinationFormPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
required this.petSpecies,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.entryPoint = HealthRecordEntryPoint.recordList,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
final PetSpecies petSpecies;
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
final HealthRecordEntryPoint entryPoint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VaccinationFormPage> createState() => _VaccinationFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VaccinationFormPageState extends State<VaccinationFormPage> {
|
||||||
|
final _seriesCtrl = TextEditingController();
|
||||||
|
final _doseNoCtrl = TextEditingController(text: '1');
|
||||||
|
final _doseLabelCtrl = TextEditingController();
|
||||||
|
final _notesCtrl = TextEditingController();
|
||||||
|
|
||||||
|
List<VaccineCatalogItem>? _catalog;
|
||||||
|
bool _catalogLoading = false;
|
||||||
|
bool _catalogFailed = false;
|
||||||
|
String? _vaccineId;
|
||||||
|
|
||||||
|
/// seriesKey 是否仍处于「目录 code 自动预填」状态(用户一改即失效)。
|
||||||
|
bool _seriesAutoFilled = true;
|
||||||
|
|
||||||
|
VaccinationStatus _status = VaccinationStatus.scheduled;
|
||||||
|
DateTime? _plannedOn;
|
||||||
|
DateTime? _administeredOn;
|
||||||
|
DateTime? _nextDueOn;
|
||||||
|
|
||||||
|
String? _vaccineError;
|
||||||
|
String? _seriesError;
|
||||||
|
String? _doseNoError;
|
||||||
|
String? _dateError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
bool _startedFired = false;
|
||||||
|
int _attemptSeq = 0;
|
||||||
|
late final DateTime _openedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_openedAt = DateTime.now();
|
||||||
|
_loadCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_seriesCtrl.dispose();
|
||||||
|
_doseNoCtrl.dispose();
|
||||||
|
_doseLabelCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCatalog() async {
|
||||||
|
setState(() {
|
||||||
|
_catalogLoading = true;
|
||||||
|
_catalogFailed = false;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final catalog = await widget.repository.listVaccineCatalog(
|
||||||
|
species: widget.petSpecies,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_catalog = catalog;
|
||||||
|
_catalogLoading = false;
|
||||||
|
});
|
||||||
|
} on ApiException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_catalogLoading = false;
|
||||||
|
_catalogFailed = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markStarted() {
|
||||||
|
if (_startedFired) return;
|
||||||
|
_startedFired = true;
|
||||||
|
widget.analytics?.createStarted(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
entryPoint: widget.entryPoint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.createFailed(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: _attemptSeq,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? get _doseNo {
|
||||||
|
final value = int.tryParse(_doseNoCtrl.text.trim());
|
||||||
|
if (value == null || value < 1 || value > 32767) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
_attemptSeq++;
|
||||||
|
final vaccineError = _vaccineId == null ? '请选择疫苗' : null;
|
||||||
|
final seriesError = _seriesCtrl.text.trim().isEmpty ? '请输入系列名称' : null;
|
||||||
|
final doseNoError = _doseNo == null ? '剂次需为 1~32767 的整数' : null;
|
||||||
|
final dateError = vaccinationDateRuleError(
|
||||||
|
status: _status,
|
||||||
|
plannedOn: _plannedOn,
|
||||||
|
administeredOn: _administeredOn,
|
||||||
|
nextDueOn: _nextDueOn,
|
||||||
|
);
|
||||||
|
if (vaccineError != null ||
|
||||||
|
seriesError != null ||
|
||||||
|
doseNoError != null ||
|
||||||
|
dateError != null) {
|
||||||
|
setState(() {
|
||||||
|
_vaccineError = vaccineError;
|
||||||
|
_seriesError = seriesError;
|
||||||
|
_doseNoError = doseNoError;
|
||||||
|
_dateError = dateError;
|
||||||
|
});
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final scheduled = _status == VaccinationStatus.scheduled;
|
||||||
|
final doseLabel = _doseLabelCtrl.text.trim();
|
||||||
|
final notes = _notesCtrl.text.trim();
|
||||||
|
final record = await widget.repository.createVaccination(
|
||||||
|
widget.petId,
|
||||||
|
CreateVaccinationRequest(
|
||||||
|
vaccineId: _vaccineId!,
|
||||||
|
seriesKey: _seriesCtrl.text.trim(),
|
||||||
|
doseNo: _doseNo!,
|
||||||
|
status: _status,
|
||||||
|
// 状态-日期规则结构化落地:scheduled 只发 plannedOn,
|
||||||
|
// completed 只发 administeredOn(+可选 nextDueOn)。
|
||||||
|
plannedOn: scheduled ? _plannedOn : null,
|
||||||
|
administeredOn: scheduled ? null : _administeredOn,
|
||||||
|
nextDueOn: scheduled ? null : _nextDueOn,
|
||||||
|
doseLabel: doseLabel.isEmpty ? null : doseLabel,
|
||||||
|
notes: notes.isEmpty ? null : notes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.analytics?.createSucceeded(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(record);
|
||||||
|
} on VaccinationDoseExistsException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('该系列该剂次已有记录(40904);如登记有误,可取消原记录后重新登记');
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError, 40904);
|
||||||
|
} on VaccinationRuleException {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 前端已拦截主路径,此处为后端状态-日期规则兜底(42201)。
|
||||||
|
_showFormError('接种状态与日期不符合规则,请核对后重试');
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError, 42201);
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('你没有权限为该宠物登记疫苗');
|
||||||
|
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||||
|
} on PetNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
|
||||||
|
_trackFailed(HealthRecordFailureReason.notFound, 40401);
|
||||||
|
navigator.pop();
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('操作过于频繁,请稍后再试');
|
||||||
|
_trackFailed(HealthRecordFailureReason.rateLimited);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final isParam = error.code == ApiCodes.paramError;
|
||||||
|
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
|
||||||
|
_trackFailed(
|
||||||
|
isParam
|
||||||
|
? HealthRecordFailureReason.validationError
|
||||||
|
: HealthRecordFailureReason.serverError,
|
||||||
|
error.code,
|
||||||
|
);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_trackFailed(HealthRecordFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('登记疫苗'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
||||||
|
children: [
|
||||||
|
..._catalogSection(),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '系列名称',
|
||||||
|
controller: _seriesCtrl,
|
||||||
|
prefixIcon: Icons.tag_outlined,
|
||||||
|
errorText: _seriesError,
|
||||||
|
helperText: '区分初免/加强等系列,同系列同剂次唯一',
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
_seriesAutoFilled = false;
|
||||||
|
if (_seriesError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_seriesError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: AppTextField(
|
||||||
|
label: '剂次',
|
||||||
|
controller: _doseNoCtrl,
|
||||||
|
prefixIcon: Icons.numbers_outlined,
|
||||||
|
errorText: _doseNoError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_doseNoError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_doseNoError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: AppTextField(
|
||||||
|
label: '剂次标签(可选)',
|
||||||
|
controller: _doseLabelCtrl,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) => _markStarted(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
const Text(
|
||||||
|
'接种状态',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<VaccinationStatus>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: VaccinationStatus.scheduled,
|
||||||
|
icon: Icon(Icons.event_outlined),
|
||||||
|
label: Text('计划接种'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: VaccinationStatus.completed,
|
||||||
|
icon: Icon(Icons.check_circle_outline),
|
||||||
|
label: Text('已完成'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_status},
|
||||||
|
onSelectionChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_status = value.first;
|
||||||
|
_dateError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
if (_status == VaccinationStatus.scheduled)
|
||||||
|
_dateTile(
|
||||||
|
label: '计划接种日期',
|
||||||
|
value: _plannedOn,
|
||||||
|
allowFuture: true,
|
||||||
|
onPicked: (value) => setState(() {
|
||||||
|
_plannedOn = value;
|
||||||
|
_dateError = null;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
_dateTile(
|
||||||
|
label: '接种日期',
|
||||||
|
value: _administeredOn,
|
||||||
|
allowFuture: false,
|
||||||
|
onPicked: (value) => setState(() {
|
||||||
|
_administeredOn = value;
|
||||||
|
_dateError = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_dateTile(
|
||||||
|
label: '下次接种日期(可选)',
|
||||||
|
value: _nextDueOn,
|
||||||
|
allowFuture: true,
|
||||||
|
onPicked: (value) => setState(() {
|
||||||
|
_nextDueOn = value;
|
||||||
|
_dateError = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_dateError != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
_dateError!,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '备注(可选)',
|
||||||
|
controller: _notesCtrl,
|
||||||
|
prefixIcon: Icons.sticky_note_2_outlined,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (_) => _markStarted(),
|
||||||
|
),
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: '保存登记',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _catalogSection() {
|
||||||
|
if (_catalogLoading) {
|
||||||
|
return const [
|
||||||
|
SizedBox(
|
||||||
|
height: 52,
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (_catalogFailed) {
|
||||||
|
return [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'疫苗目录加载失败,请重试后选择',
|
||||||
|
style: TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: _loadCatalog, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
final catalog = _catalog ?? const <VaccineCatalogItem>[];
|
||||||
|
if (catalog.isEmpty) {
|
||||||
|
return const [
|
||||||
|
Text(
|
||||||
|
'该物种暂无可选疫苗目录',
|
||||||
|
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _vaccineId,
|
||||||
|
decoration: InputDecoration(labelText: '疫苗', errorText: _vaccineError),
|
||||||
|
items: [
|
||||||
|
for (final item in catalog)
|
||||||
|
DropdownMenuItem(value: item.id, child: Text(item.name)),
|
||||||
|
],
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_vaccineId = value;
|
||||||
|
_vaccineError = null;
|
||||||
|
_formError = null;
|
||||||
|
// seriesKey 以目录 code 预填(用户未改过才覆盖)。
|
||||||
|
if (_seriesAutoFilled && value != null) {
|
||||||
|
final item = catalog.firstWhere((c) => c.id == value);
|
||||||
|
_seriesCtrl.text = item.code;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dateTile({
|
||||||
|
required String label,
|
||||||
|
required DateTime? value,
|
||||||
|
required bool allowFuture,
|
||||||
|
required ValueChanged<DateTime> onPicked,
|
||||||
|
}) {
|
||||||
|
return ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
),
|
||||||
|
tileColor: AppColors.surface,
|
||||||
|
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||||
|
title: Text(label, style: const TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
value == null ? '未选择' : dateToJson(value),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.calendar_month_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
enabled: !_submitting,
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: value ?? now,
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: allowFuture ? DateTime(now.year + 5) : now,
|
||||||
|
);
|
||||||
|
if (picked != null && mounted) {
|
||||||
|
_markStarted();
|
||||||
|
onPicked(picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
|
enum _ListPhase { loading, ready, error }
|
||||||
|
|
||||||
|
/// 疫苗记录列表页(T2-13):契约不分页,服务端按
|
||||||
|
/// `series_key, dose_no, created_at, id` 排序,客户端按系列直接分组;
|
||||||
|
/// 含 cancelled 行原样展示(取消后同剂次可重新登记的事实留痕)。
|
||||||
|
/// 四态齐备;登记经 [VaccinationFormPage]。
|
||||||
|
///
|
||||||
|
/// T2-14 收尾(25 号报告 §7 遗留①②):scheduled 行支持「标记完成 /
|
||||||
|
/// 取消登记」PATCH 流转;完成时可补录厂商/批号(契约可选字段);
|
||||||
|
/// 挂 `health_record_edit_succeeded/failed`(recordType=vaccine)。
|
||||||
|
///
|
||||||
|
/// 曝光埋点:每次进入首个成功加载上报一次
|
||||||
|
/// `health_record_viewed(recordType=vaccine, source=pet_detail)`。
|
||||||
|
class VaccinationRecordsPage extends StatefulWidget {
|
||||||
|
const VaccinationRecordsPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
required this.petSpecies,
|
||||||
|
required this.canWrite,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
|
||||||
|
/// 疫苗目录按宠物物种过滤(契约:疫苗 species 须与宠物一致)。
|
||||||
|
final PetSpecies petSpecies;
|
||||||
|
|
||||||
|
/// owner/caregiver 可写;viewer 隐藏登记入口。
|
||||||
|
final bool canWrite;
|
||||||
|
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VaccinationRecordsPage> createState() => _VaccinationRecordsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
||||||
|
_ListPhase _phase = _ListPhase.loading;
|
||||||
|
List<Vaccination> _records = const [];
|
||||||
|
ApiException? _error;
|
||||||
|
bool _viewedFired = false;
|
||||||
|
bool _mutating = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_phase = _ListPhase.loading;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final records = await widget.repository.listVaccinations(widget.petId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_records = records;
|
||||||
|
_phase = _ListPhase.ready;
|
||||||
|
});
|
||||||
|
if (!_viewedFired) {
|
||||||
|
_viewedFired = true;
|
||||||
|
widget.analytics?.viewed(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
source: HealthRecordViewSource.petDetail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error;
|
||||||
|
_phase = _ListPhase.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCreate() async {
|
||||||
|
final created = await Navigator.of(context).push<Vaccination>(
|
||||||
|
fadePageRoute(
|
||||||
|
VaccinationFormPage(
|
||||||
|
repository: widget.repository,
|
||||||
|
petId: widget.petId,
|
||||||
|
petSpecies: widget.petSpecies,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (created != null && mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已登记疫苗')));
|
||||||
|
// 排序键在服务端(series_key, dose_no),重新拉取而非本地猜位置。
|
||||||
|
await _load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackEditFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.editFailed(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
reason: reason,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记完成(25 号报告遗留①②):接种日期必填、下次接种/厂商/批号可选
|
||||||
|
/// (厂商/批号为契约可选字段的补录入口)。
|
||||||
|
Future<void> _markCompleted(Vaccination record) async {
|
||||||
|
final result = await showDialog<_CompleteVaccinationResult>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _CompleteVaccinationDialog(record: record),
|
||||||
|
);
|
||||||
|
if (result == null || !mounted) return;
|
||||||
|
await _mutate(
|
||||||
|
record,
|
||||||
|
UpdateVaccinationRequest(
|
||||||
|
version: record.version,
|
||||||
|
status: VaccinationStatus.completed,
|
||||||
|
administeredOn: result.administeredOn,
|
||||||
|
nextDueOn: result.nextDueOn,
|
||||||
|
manufacturer: result.manufacturer,
|
||||||
|
batchNo: result.batchNo,
|
||||||
|
),
|
||||||
|
successText: '已标记完成',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelRegistration(Vaccination record) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('取消这条登记?'),
|
||||||
|
content: Text(
|
||||||
|
'「${vaccinationDoseLabel(record)}」将标记为已取消;'
|
||||||
|
'取消后同系列同剂次可重新登记。',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('返回'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('取消登记'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
await _mutate(
|
||||||
|
record,
|
||||||
|
UpdateVaccinationRequest(
|
||||||
|
version: record.version,
|
||||||
|
status: VaccinationStatus.cancelled,
|
||||||
|
),
|
||||||
|
successText: '已取消登记',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _mutate(
|
||||||
|
Vaccination record,
|
||||||
|
UpdateVaccinationRequest request, {
|
||||||
|
required String successText,
|
||||||
|
}) async {
|
||||||
|
if (_mutating) return;
|
||||||
|
setState(() => _mutating = true);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
try {
|
||||||
|
await widget.repository.updateVaccination(record.id, request);
|
||||||
|
widget.analytics?.editSucceeded(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
fieldCount: request.toJson().length - 1,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(successText)));
|
||||||
|
await _load();
|
||||||
|
} on PetVersionConflictException {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 40902:并发修改抢先——刷新取新 version 后由用户重试动作。
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('记录已在其他设备被修改,已刷新,请重试')),
|
||||||
|
);
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.conflict, 40902);
|
||||||
|
await _load();
|
||||||
|
} on VaccinationRuleException {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 42201:状态机/状态-日期规则兜底(前端已按规则拦截主路径)。
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('接种状态与日期不符合规则,请核对后重试')),
|
||||||
|
);
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.validationError, 42201);
|
||||||
|
} on PetRecordNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('记录不存在或已被删除,已刷新')));
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.notFound, 40402);
|
||||||
|
await _load();
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该记录')));
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.serverError, error.code);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
|
||||||
|
_trackEditFailed(HealthRecordFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _mutating = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('疫苗记录'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (widget.canWrite && _phase == _ListPhase.ready)
|
||||||
|
IconButton(
|
||||||
|
tooltip: '登记疫苗',
|
||||||
|
onPressed: _openCreate,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: switch (_phase) {
|
||||||
|
_ListPhase.loading => const Center(child: CircularProgressIndicator()),
|
||||||
|
_ListPhase.error => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
InlineErrorBanner(message: petLoadErrorMessage(_error)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(onPressed: _load, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready when _records.isEmpty => Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: EmptyStateIllustration(
|
||||||
|
icon: Icons.vaccines_outlined,
|
||||||
|
title: '还没有疫苗记录',
|
||||||
|
description: '登记接种计划与完成情况,不错过每一针',
|
||||||
|
ctaLabel: widget.canWrite ? '登记第一针' : null,
|
||||||
|
onCtaPressed: widget.canWrite ? _openCreate : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready => _list(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按系列分组渲染:服务端排序保证同系列相邻,系列变化处插组头
|
||||||
|
/// (疫苗名 · 系列键)。
|
||||||
|
Widget _list() {
|
||||||
|
final children = <Widget>[];
|
||||||
|
String? currentSeries;
|
||||||
|
for (final record in _records) {
|
||||||
|
final seriesId = '${record.vaccineId}/${record.seriesKey}';
|
||||||
|
if (seriesId != currentSeries) {
|
||||||
|
currentSeries = seriesId;
|
||||||
|
children.add(
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: children.isEmpty ? 0 : 14, bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
'${record.vaccineName} · ${record.seriesKey}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
children
|
||||||
|
..add(
|
||||||
|
_VaccinationTile(
|
||||||
|
record: record,
|
||||||
|
// 流转动作仅 scheduled 行可用(终态由服务端状态机守卫)。
|
||||||
|
onComplete:
|
||||||
|
widget.canWrite &&
|
||||||
|
record.status == VaccinationStatus.scheduled &&
|
||||||
|
!_mutating
|
||||||
|
? () => _markCompleted(record)
|
||||||
|
: null,
|
||||||
|
onCancel:
|
||||||
|
widget.canWrite &&
|
||||||
|
record.status == VaccinationStatus.scheduled &&
|
||||||
|
!_mutating
|
||||||
|
? () => _cancelRegistration(record)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(const SizedBox(height: 10));
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 疫苗条目:RecordTypeDot(疫苗) + 剂次标题 + 日期副行 + 状态 TagPill
|
||||||
|
/// (图标+文字双通道,不单靠颜色区分);scheduled 行附
|
||||||
|
/// 「标记完成 / 取消登记」流转动作。
|
||||||
|
class _VaccinationTile extends StatelessWidget {
|
||||||
|
const _VaccinationTile({
|
||||||
|
required this.record,
|
||||||
|
this.onComplete,
|
||||||
|
this.onCancel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Vaccination record;
|
||||||
|
final VoidCallback? onComplete;
|
||||||
|
final VoidCallback? onCancel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const RecordTypeDot(
|
||||||
|
type: RecordType.vaccine,
|
||||||
|
size: RecordTypeDotSize.md,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
vaccinationDoseLabel(record),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
vaccinationDateLine(record),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TagPill(
|
||||||
|
vaccinationStatusLabel(record.status),
|
||||||
|
color: vaccinationStatusColor(record.status),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (onComplete != null || onCancel != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onCancel,
|
||||||
|
icon: const Icon(Icons.close, size: 16),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
label: const Text('取消登记'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onComplete,
|
||||||
|
icon: const Icon(Icons.check_circle_outline, size: 16),
|
||||||
|
label: const Text('标记完成'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记完成对话框返回值。
|
||||||
|
class _CompleteVaccinationResult {
|
||||||
|
const _CompleteVaccinationResult({
|
||||||
|
required this.administeredOn,
|
||||||
|
this.nextDueOn,
|
||||||
|
this.manufacturer,
|
||||||
|
this.batchNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime administeredOn;
|
||||||
|
final DateTime? nextDueOn;
|
||||||
|
final String? manufacturer;
|
||||||
|
final String? batchNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记完成对话框:接种日期必填(默认今天)、下次接种可选;
|
||||||
|
/// 厂商/批号补录(契约可选字段,25 号报告遗留②的落地入口)。
|
||||||
|
/// 日期规则复用 [vaccinationDateRuleError](42201 前置拦截)。
|
||||||
|
class _CompleteVaccinationDialog extends StatefulWidget {
|
||||||
|
const _CompleteVaccinationDialog({required this.record});
|
||||||
|
|
||||||
|
final Vaccination record;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CompleteVaccinationDialog> createState() =>
|
||||||
|
_CompleteVaccinationDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompleteVaccinationDialogState
|
||||||
|
extends State<_CompleteVaccinationDialog> {
|
||||||
|
final _manufacturerCtrl = TextEditingController();
|
||||||
|
final _batchNoCtrl = TextEditingController();
|
||||||
|
DateTime _administeredOn = DateTime.now();
|
||||||
|
DateTime? _nextDueOn;
|
||||||
|
String? _dateError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_manufacturerCtrl.text = widget.record.manufacturer ?? '';
|
||||||
|
_batchNoCtrl.text = widget.record.batchNo ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_manufacturerCtrl.dispose();
|
||||||
|
_batchNoCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirm() {
|
||||||
|
final error = vaccinationDateRuleError(
|
||||||
|
status: VaccinationStatus.completed,
|
||||||
|
administeredOn: _administeredOn,
|
||||||
|
nextDueOn: _nextDueOn,
|
||||||
|
);
|
||||||
|
if (error != null) {
|
||||||
|
setState(() => _dateError = error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final manufacturer = _manufacturerCtrl.text.trim();
|
||||||
|
final batchNo = _batchNoCtrl.text.trim();
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
_CompleteVaccinationResult(
|
||||||
|
administeredOn: _administeredOn,
|
||||||
|
nextDueOn: _nextDueOn,
|
||||||
|
manufacturer: manufacturer.isEmpty ? null : manufacturer,
|
||||||
|
batchNo: batchNo.isEmpty ? null : batchNo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dateTile({
|
||||||
|
required String label,
|
||||||
|
required DateTime? value,
|
||||||
|
required bool allowFuture,
|
||||||
|
required ValueChanged<DateTime> onPicked,
|
||||||
|
}) {
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||||
|
title: Text(label, style: const TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
value == null ? '未选择' : dateToJson(value),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: value ?? now,
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: allowFuture ? DateTime(now.year + 5) : now,
|
||||||
|
);
|
||||||
|
if (picked != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
onPicked(picked);
|
||||||
|
_dateError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('标记完成'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'「${vaccinationDoseLabel(widget.record)}」',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_dateTile(
|
||||||
|
label: '接种日期',
|
||||||
|
value: _administeredOn,
|
||||||
|
allowFuture: false,
|
||||||
|
onPicked: (value) => _administeredOn = value,
|
||||||
|
),
|
||||||
|
_dateTile(
|
||||||
|
label: '下次接种日期(可选)',
|
||||||
|
value: _nextDueOn,
|
||||||
|
allowFuture: true,
|
||||||
|
onPicked: (value) => _nextDueOn = value,
|
||||||
|
),
|
||||||
|
if (_dateError != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_dateError!,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppTextField(
|
||||||
|
label: '厂商(可选)',
|
||||||
|
controller: _manufacturerCtrl,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppTextField(
|
||||||
|
label: '批号(可选)',
|
||||||
|
controller: _batchNoCtrl,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _confirm(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(onPressed: _confirm, child: const Text('确认完成')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
/// 体重录入表单页(T2-13)。
|
||||||
|
///
|
||||||
|
/// - `weightKg` 前端校验对齐契约:(0, 500]、最多两位小数;后端 40000 兜底。
|
||||||
|
/// - 称重时间默认「现在」,可回选日期(非今日取当日 12:00,保持
|
||||||
|
/// measured_at DESC 排序直觉);提交前转 UTC,ISO 8601 带 Z 上送。
|
||||||
|
/// - 错误分层沿用登录纵切:字段 errorText / InlineErrorBanner / SnackBar。
|
||||||
|
/// - 埋点:首次输入 health_record_create_started(recordType=weight),
|
||||||
|
/// 成功/失败按 06 §1.4 经 [HealthRecordAnalytics] 上报。
|
||||||
|
class WeightFormPage extends StatefulWidget {
|
||||||
|
const WeightFormPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.entryPoint = HealthRecordEntryPoint.recordList,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
final HealthRecordEntryPoint entryPoint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WeightFormPage> createState() => _WeightFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeightFormPageState extends State<WeightFormPage> {
|
||||||
|
final _weightCtrl = TextEditingController();
|
||||||
|
final _noteCtrl = TextEditingController();
|
||||||
|
|
||||||
|
DateTime _measuredDate = DateTime.now();
|
||||||
|
String? _weightError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
bool _startedFired = false;
|
||||||
|
int _attemptSeq = 0;
|
||||||
|
late final DateTime _openedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_openedAt = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_weightCtrl.dispose();
|
||||||
|
_noteCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markStarted() {
|
||||||
|
if (_startedFired) return;
|
||||||
|
_startedFired = true;
|
||||||
|
widget.analytics?.createStarted(
|
||||||
|
recordType: HealthRecordType.weight,
|
||||||
|
entryPoint: widget.entryPoint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.createFailed(
|
||||||
|
recordType: HealthRecordType.weight,
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: _attemptSeq,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateWeight() {
|
||||||
|
final text = _weightCtrl.text.trim();
|
||||||
|
if (text.isEmpty) return '请输入体重';
|
||||||
|
if (parseWeightKgInput(text) == null) {
|
||||||
|
return '体重需大于 0 且不超过 500 公斤,最多两位小数';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 称重时刻:今日取此刻,历史日期取当日 12:00(本地),提交前转 UTC。
|
||||||
|
DateTime _measuredAt() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final sameDay =
|
||||||
|
_measuredDate.year == now.year &&
|
||||||
|
_measuredDate.month == now.month &&
|
||||||
|
_measuredDate.day == now.day;
|
||||||
|
final local = sameDay
|
||||||
|
? now
|
||||||
|
: DateTime(
|
||||||
|
_measuredDate.year,
|
||||||
|
_measuredDate.month,
|
||||||
|
_measuredDate.day,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
return local.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
_attemptSeq++;
|
||||||
|
final weightError = _validateWeight();
|
||||||
|
if (weightError != null) {
|
||||||
|
setState(() => _weightError = weightError);
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final note = _noteCtrl.text.trim();
|
||||||
|
final record = await widget.repository.createWeight(
|
||||||
|
widget.petId,
|
||||||
|
CreateWeightRequest(
|
||||||
|
weightKg: parseWeightKgInput(_weightCtrl.text)!,
|
||||||
|
measuredAt: _measuredAt(),
|
||||||
|
note: note.isEmpty ? null : note,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.analytics?.createSucceeded(
|
||||||
|
recordType: HealthRecordType.weight,
|
||||||
|
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(record);
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('你没有权限为该宠物添加记录');
|
||||||
|
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||||
|
} on PetNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
|
||||||
|
_trackFailed(HealthRecordFailureReason.notFound, 40401);
|
||||||
|
navigator.pop();
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('操作过于频繁,请稍后再试');
|
||||||
|
_trackFailed(HealthRecordFailureReason.rateLimited);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final isParam = error.code == ApiCodes.paramError;
|
||||||
|
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
|
||||||
|
_trackFailed(
|
||||||
|
isParam
|
||||||
|
? HealthRecordFailureReason.validationError
|
||||||
|
: HealthRecordFailureReason.serverError,
|
||||||
|
error.code,
|
||||||
|
);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_trackFailed(HealthRecordFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('记录体重'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
||||||
|
children: [
|
||||||
|
AppTextField(
|
||||||
|
label: '体重(公斤)',
|
||||||
|
controller: _weightCtrl,
|
||||||
|
prefixIcon: Icons.monitor_weight_outlined,
|
||||||
|
errorText: _weightError,
|
||||||
|
helperText: '大于 0 且不超过 500,最多两位小数',
|
||||||
|
enabled: !_submitting,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_weightError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_weightError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
),
|
||||||
|
tileColor: AppColors.surface,
|
||||||
|
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||||
|
title: const Text('称重日期', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
dateToJson(_measuredDate),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.calendar_month_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
enabled: !_submitting,
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final value = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _measuredDate,
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: now,
|
||||||
|
);
|
||||||
|
if (value != null && mounted) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() => _measuredDate = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '备注(可选,如:饭后称重)',
|
||||||
|
controller: _noteCtrl,
|
||||||
|
prefixIcon: Icons.sticky_note_2_outlined,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (_) => _markStarted(),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: '保存记录',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/weight_form_page.dart';
|
||||||
|
|
||||||
|
enum _ListPhase { loading, ready, error }
|
||||||
|
|
||||||
|
/// 体重历史列表页(T2-13):cursor 分页(「加载更多」追加,末页收起),
|
||||||
|
/// 四态齐备;新记录经 [WeightFormPage] 录入后就地插入列表头。
|
||||||
|
///
|
||||||
|
/// 曝光埋点:每次进入首个成功加载上报一次
|
||||||
|
/// `health_record_viewed(recordType=weight, source=pet_detail)`
|
||||||
|
/// (工单口径:列表曝光;不随滚动逐条上报)。
|
||||||
|
class WeightRecordsPage extends StatefulWidget {
|
||||||
|
const WeightRecordsPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
required this.canWrite,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
|
||||||
|
/// owner/caregiver 可写;viewer 隐藏录入入口(40300 语义前置)。
|
||||||
|
final bool canWrite;
|
||||||
|
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
|
||||||
|
/// 每页条数(测试注入小页验证分页;缺省走服务端默认 20)。
|
||||||
|
final int? pageSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WeightRecordsPage> createState() => _WeightRecordsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeightRecordsPageState extends State<WeightRecordsPage> {
|
||||||
|
_ListPhase _phase = _ListPhase.loading;
|
||||||
|
List<WeightRecord> _records = const [];
|
||||||
|
String? _nextCursor;
|
||||||
|
bool _hasMore = false;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
ApiException? _error;
|
||||||
|
bool _viewedFired = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadFirstPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFirstPage() async {
|
||||||
|
setState(() {
|
||||||
|
_phase = _ListPhase.loading;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final page = await widget.repository.listWeights(
|
||||||
|
widget.petId,
|
||||||
|
limit: widget.pageSize,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_records = page.items;
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_phase = _ListPhase.ready;
|
||||||
|
});
|
||||||
|
if (!_viewedFired) {
|
||||||
|
_viewedFired = true;
|
||||||
|
widget.analytics?.viewed(
|
||||||
|
recordType: HealthRecordType.weight,
|
||||||
|
source: HealthRecordViewSource.petDetail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error;
|
||||||
|
_phase = _ListPhase.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMore() async {
|
||||||
|
if (_loadingMore || !_hasMore) return;
|
||||||
|
setState(() => _loadingMore = true);
|
||||||
|
try {
|
||||||
|
final page = await widget.repository.listWeights(
|
||||||
|
widget.petId,
|
||||||
|
limit: widget.pageSize,
|
||||||
|
cursor: _nextCursor,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_records = [..._records, ...page.items];
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
});
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loadingMore = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCreate() async {
|
||||||
|
final created = await Navigator.of(context).push<WeightRecord>(
|
||||||
|
fadePageRoute(
|
||||||
|
WeightFormPage(
|
||||||
|
repository: widget.repository,
|
||||||
|
petId: widget.petId,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
// record_form:健康记录漏斗到达段页名(06 §1.6 / §2.2)。
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (created != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
// 契约排序 measured_at DESC, id DESC:新记录通常最新,就地插头;
|
||||||
|
// 补录历史日期的位置以下次刷新为准(服务端是唯一事实来源)。
|
||||||
|
_records = [created, ..._records];
|
||||||
|
if (_phase != _ListPhase.ready) _phase = _ListPhase.ready;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已记录体重')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: const Text('体重记录'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (widget.canWrite && _phase == _ListPhase.ready)
|
||||||
|
IconButton(
|
||||||
|
tooltip: '记录体重',
|
||||||
|
onPressed: _openCreate,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: switch (_phase) {
|
||||||
|
_ListPhase.loading => const Center(child: CircularProgressIndicator()),
|
||||||
|
_ListPhase.error => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
InlineErrorBanner(message: petLoadErrorMessage(_error)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _loadFirstPage,
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready when _records.isEmpty => Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: EmptyStateIllustration(
|
||||||
|
icon: Icons.monitor_weight_outlined,
|
||||||
|
title: '还没有体重记录',
|
||||||
|
description: '定期称重,看见毛孩子的成长曲线',
|
||||||
|
ctaLabel: widget.canWrite ? '记录第一条' : null,
|
||||||
|
onCtaPressed: widget.canWrite ? _openCreate : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready => _list(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _list() {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadFirstPage,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
|
||||||
|
children: [
|
||||||
|
for (final record in _records) ...[
|
||||||
|
_WeightTile(record: record),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
if (_hasMore)
|
||||||
|
Center(
|
||||||
|
child: _loadingMore
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: TextButton(onPressed: _loadMore, child: const Text('加载更多')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 体重条目:RecordTypeDot(体重) + 大数值(primaryDark w800)+
|
||||||
|
/// 日期/备注副行(05 §3.3 时间线条目形态的数值型变体)。
|
||||||
|
class _WeightTile extends StatelessWidget {
|
||||||
|
const _WeightTile({required this.record});
|
||||||
|
|
||||||
|
final WeightRecord record;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final meta = StringBuffer(formatMeasuredAt(record.measuredAt));
|
||||||
|
if (record.note != null && record.note!.isNotEmpty) {
|
||||||
|
meta.write(' · ${record.note}');
|
||||||
|
}
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const RecordTypeDot(
|
||||||
|
type: RecordType.weight,
|
||||||
|
size: RecordTypeDotSize.md,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${formatWeightKg(record.weightKg)} kg',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
meta.toString(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 称重时刻展示:本地时区 `YYYY-MM-DD HH:mm`。
|
||||||
|
String formatMeasuredAt(DateTime measuredAt) {
|
||||||
|
final local = measuredAt.toLocal();
|
||||||
|
final h = local.hour.toString().padLeft(2, '0');
|
||||||
|
final min = local.minute.toString().padLeft(2, '0');
|
||||||
|
return '${dateToJson(local)} $h:$min';
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,13 @@ import 'package:patbond_flutter/state/app_state.dart';
|
|||||||
import 'package:patbond_flutter/widgets/common.dart';
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
class ProfilePage extends StatelessWidget {
|
class ProfilePage extends StatelessWidget {
|
||||||
const ProfilePage({required this.appState, super.key});
|
const ProfilePage({required this.appState, super.key, this.onLogout});
|
||||||
|
|
||||||
final AppState appState;
|
final AppState appState;
|
||||||
|
|
||||||
|
/// 真实退出登录入口;未接线时保持演示提示。
|
||||||
|
final Future<void> Function()? onLogout;
|
||||||
|
|
||||||
static const menuItems = [
|
static const menuItems = [
|
||||||
(Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'),
|
(Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'),
|
||||||
(Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'),
|
(Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'),
|
||||||
@@ -93,7 +96,10 @@ class ProfilePage extends StatelessWidget {
|
|||||||
const _ProfileStat(value: '24', label: '关注我'),
|
const _ProfileStat(value: '24', label: '关注我'),
|
||||||
const _ProfileStat(value: '1.8k', label: '获赞'),
|
const _ProfileStat(value: '1.8k', label: '获赞'),
|
||||||
_ProfileStat(
|
_ProfileStat(
|
||||||
value: '${appState.posts.length}',
|
// 「我的资料」头部整体仍是 demo 家具(M5 范围):三项
|
||||||
|
// 统计同源 demo 常量;AppState.posts 随 T3-17 退役后
|
||||||
|
// 本项直读 demo 列表长度,不伪装真实数据。
|
||||||
|
value: '${initialPosts.length}',
|
||||||
label: '我的作品',
|
label: '我的作品',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -133,7 +139,7 @@ class ProfilePage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => showDemoMessage(context, '退出登录'),
|
onPressed: onLogout ?? () => showDemoMessage(context, '退出登录'),
|
||||||
child: const Text('切换账号或退出登录'),
|
child: const Text('切换账号或退出登录'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -134,85 +134,6 @@ class PetProfile {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VaccineStatus { completed, pending }
|
|
||||||
|
|
||||||
class VaccineItem {
|
|
||||||
const VaccineItem({
|
|
||||||
required this.id,
|
|
||||||
required this.name,
|
|
||||||
required this.status,
|
|
||||||
required this.date,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String id;
|
|
||||||
final String name;
|
|
||||||
final VaccineStatus status;
|
|
||||||
final String date;
|
|
||||||
|
|
||||||
VaccineItem copyWith({VaccineStatus? status, String? date}) {
|
|
||||||
return VaccineItem(
|
|
||||||
id: id,
|
|
||||||
name: name,
|
|
||||||
status: status ?? this.status,
|
|
||||||
date: date ?? this.date,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
factory VaccineItem.fromJson(Map<String, dynamic> json) {
|
|
||||||
return VaccineItem(
|
|
||||||
id: json['id'] as String,
|
|
||||||
name: json['name'] as String,
|
|
||||||
status: json['status'] == 'completed'
|
|
||||||
? VaccineStatus.completed
|
|
||||||
: VaccineStatus.pending,
|
|
||||||
date: json['date'] as String,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'id': id,
|
|
||||||
'name': name,
|
|
||||||
'status': status.name,
|
|
||||||
'date': date,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class VaccineRecord {
|
|
||||||
const VaccineRecord({
|
|
||||||
required this.completedDoses,
|
|
||||||
required this.totalDoses,
|
|
||||||
required this.items,
|
|
||||||
required this.reminderVaccine,
|
|
||||||
required this.reminderDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int completedDoses;
|
|
||||||
final int totalDoses;
|
|
||||||
final List<VaccineItem> items;
|
|
||||||
final String reminderVaccine;
|
|
||||||
final String reminderDate;
|
|
||||||
|
|
||||||
factory VaccineRecord.fromJson(Map<String, dynamic> json) {
|
|
||||||
return VaccineRecord(
|
|
||||||
completedDoses: json['completedDoses'] as int,
|
|
||||||
totalDoses: json['totalDoses'] as int,
|
|
||||||
items: (json['items'] as List)
|
|
||||||
.map((item) => VaccineItem.fromJson(item as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
reminderVaccine: json['reminderVaccine'] as String,
|
|
||||||
reminderDate: json['reminderDate'] as String,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'completedDoses': completedDoses,
|
|
||||||
'totalDoses': totalDoses,
|
|
||||||
'items': items.map((item) => item.toJson()).toList(),
|
|
||||||
'reminderVaccine': reminderVaccine,
|
|
||||||
'reminderDate': reminderDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class CommentModel {
|
class CommentModel {
|
||||||
const CommentModel({
|
const CommentModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
class AppState extends ChangeNotifier {
|
class AppState extends ChangeNotifier {
|
||||||
static const _petKey = 'patbond_pet';
|
static const _petKey = 'patbond_pet';
|
||||||
static const _vaccinesKey = 'patbond_vaccines';
|
|
||||||
static const _postsKey = 'patbond_posts';
|
|
||||||
static const _locationWeatherKey = 'patbond_location_weather';
|
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;
|
PetProfile pet = initialPet;
|
||||||
VaccineRecord vaccines = initialVaccines;
|
|
||||||
List<PostModel> posts = List<PostModel>.from(initialPosts);
|
|
||||||
LocationWeather locationWeather = initialLocationWeather;
|
LocationWeather locationWeather = initialLocationWeather;
|
||||||
bool isReady = false;
|
bool isReady = false;
|
||||||
|
|
||||||
@@ -21,23 +24,11 @@ class AppState extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
final savedPet = preferences.getString(_petKey);
|
final savedPet = preferences.getString(_petKey);
|
||||||
final savedVaccines = preferences.getString(_vaccinesKey);
|
|
||||||
final savedPosts = preferences.getString(_postsKey);
|
|
||||||
final savedLocationWeather = preferences.getString(_locationWeatherKey);
|
final savedLocationWeather = preferences.getString(_locationWeatherKey);
|
||||||
|
|
||||||
if (savedPet != null) {
|
if (savedPet != null) {
|
||||||
pet = PetProfile.fromJson(jsonDecode(savedPet) as Map<String, dynamic>);
|
pet = PetProfile.fromJson(jsonDecode(savedPet) as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
if (savedVaccines != null) {
|
|
||||||
vaccines = VaccineRecord.fromJson(
|
|
||||||
jsonDecode(savedVaccines) as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (savedPosts != null) {
|
|
||||||
posts = (jsonDecode(savedPosts) as List)
|
|
||||||
.map((item) => PostModel.fromJson(item as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
if (savedLocationWeather != null) {
|
if (savedLocationWeather != null) {
|
||||||
locationWeather = LocationWeather.fromJson(
|
locationWeather = LocationWeather.fromJson(
|
||||||
jsonDecode(savedLocationWeather) as Map<String, dynamic>,
|
jsonDecode(savedLocationWeather) as Map<String, dynamic>,
|
||||||
@@ -46,8 +37,6 @@ class AppState extends ChangeNotifier {
|
|||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
|
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
|
||||||
pet = initialPet;
|
pet = initialPet;
|
||||||
vaccines = initialVaccines;
|
|
||||||
posts = List<PostModel>.from(initialPosts);
|
|
||||||
locationWeather = initialLocationWeather;
|
locationWeather = initialLocationWeather;
|
||||||
} finally {
|
} finally {
|
||||||
isReady = true;
|
isReady = true;
|
||||||
@@ -55,33 +44,6 @@ class AppState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updatePet(PetProfile value) async {
|
|
||||||
pet = value;
|
|
||||||
notifyListeners();
|
|
||||||
await _save(_petKey, value.toJson());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateVaccines(VaccineRecord value) async {
|
|
||||||
vaccines = value;
|
|
||||||
notifyListeners();
|
|
||||||
await _save(_vaccinesKey, value.toJson());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updatePost(PostModel value) async {
|
|
||||||
final index = posts.indexWhere((post) => post.id == value.id);
|
|
||||||
if (index == -1) return;
|
|
||||||
posts[index] = value;
|
|
||||||
posts = List<PostModel>.from(posts);
|
|
||||||
notifyListeners();
|
|
||||||
await _savePosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> publishPost(PostModel value) async {
|
|
||||||
posts = [value, ...posts];
|
|
||||||
notifyListeners();
|
|
||||||
await _savePosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateLocationWeather(LocationWeather value) async {
|
Future<void> updateLocationWeather(LocationWeather value) async {
|
||||||
locationWeather = value;
|
locationWeather = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -90,23 +52,15 @@ class AppState extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> resetDemoData() async {
|
Future<void> resetDemoData() async {
|
||||||
pet = initialPet;
|
pet = initialPet;
|
||||||
vaccines = initialVaccines;
|
|
||||||
posts = List<PostModel>.from(initialPosts);
|
|
||||||
locationWeather = initialLocationWeather;
|
locationWeather = initialLocationWeather;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
preferences.remove(_petKey),
|
preferences.remove(_petKey),
|
||||||
preferences.remove(_vaccinesKey),
|
|
||||||
preferences.remove(_postsKey),
|
|
||||||
preferences.remove(_locationWeatherKey),
|
preferences.remove(_locationWeatherKey),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _savePosts() {
|
|
||||||
return _save(_postsKey, posts.map((post) => post.toJson()).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _save(String key, Object value) async {
|
Future<void> _save(String key, Object value) async {
|
||||||
try {
|
try {
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
|||||||
+34
-4
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/signed_network_image.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
|
||||||
class RemoteImage extends StatelessWidget {
|
class RemoteImage extends StatelessWidget {
|
||||||
@@ -21,8 +22,10 @@ class RemoteImage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
child: Image.network(
|
// 缓存 key 剥离预签名参数(T3-14):媒体 URL 每次响应现签,
|
||||||
url,
|
// 按完整 URL 缓存会同图重复下载。
|
||||||
|
child: Image(
|
||||||
|
image: SignedNetworkImage(url),
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
@@ -73,14 +76,41 @@ class SectionCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 胶囊标签:底色为 [color] 的 8% 淡染(`withAlpha(20)`),文字用深变体。
|
||||||
|
///
|
||||||
|
/// DEBT-1 偿还(05 号规范 §5.3):原实现文字直接用 [color],在自身淡底
|
||||||
|
/// 上仅 1.67–2.55:1 不达 WCAG AA;现文字色按内置映射取深变体
|
||||||
|
/// (primary→primaryDark 8.74:1、success→successInk 7.39:1、
|
||||||
|
/// accent→accentDark 7.07:1、error→errorDark 5.78:1、未命中→ink 兜底),
|
||||||
|
/// 或经 [inkColor] 显式指定。既有调用零参数变更、仅视觉变深。
|
||||||
class TagPill extends StatelessWidget {
|
class TagPill extends StatelessWidget {
|
||||||
const TagPill(this.label, {super.key, this.color = AppColors.primary});
|
const TagPill(
|
||||||
|
this.label, {
|
||||||
|
super.key,
|
||||||
|
this.color = AppColors.primary,
|
||||||
|
this.inkColor,
|
||||||
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
|
/// 底色基色(淡染 8% 后作背景)。
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
|
/// 文字色;缺省按 [color] 查内置深变体映射。
|
||||||
|
final Color? inkColor;
|
||||||
|
|
||||||
|
/// 内置深变体映射(Color 重载 == 不能作 const map 键,用查表函数)。
|
||||||
|
static Color _defaultInkFor(Color color) {
|
||||||
|
if (color == AppColors.primary) return AppColors.primaryDark;
|
||||||
|
if (color == AppColors.success) return AppColors.successInk;
|
||||||
|
if (color == AppColors.accent) return AppColors.accentDark;
|
||||||
|
if (color == AppColors.error) return AppColors.errorDark;
|
||||||
|
return AppColors.ink;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final effectiveInk = inkColor ?? _defaultInkFor(color);
|
||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withAlpha(20),
|
color: color.withAlpha(20),
|
||||||
@@ -91,7 +121,7 @@ class TagPill extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: color,
|
color: effectiveInk,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||||
|
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
|
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_linux
|
||||||
|
flutter_secure_storage_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -5,8 +5,16 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import file_selector_macos
|
||||||
|
import flutter_image_compress_macos
|
||||||
|
import flutter_secure_storage_darwin
|
||||||
|
import package_info_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
|
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
||||||
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
+472
-1
@@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -33,6 +41,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
|
code_assets:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_assets
|
||||||
|
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -41,6 +57,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+5"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -49,8 +81,24 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
version: "1.0.9"
|
||||||
fake_async:
|
dio:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.11.1"
|
||||||
|
dio_web_adapter:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.2"
|
||||||
|
fake_async:
|
||||||
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: fake_async
|
name: fake_async
|
||||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
@@ -65,6 +113,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
ffi_leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi_leak_tracker
|
||||||
|
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.2"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -73,11 +129,104 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4+1"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5+1"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+6"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_driver:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_image_compress:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress
|
||||||
|
sha256: "98a48c05a7add6869c6838270e862124a4d571f9dd5d0cf209ed03a71b20ea84"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.1"
|
||||||
|
flutter_image_compress_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress_common
|
||||||
|
sha256: "76869e4d5f3d65f3431e7edff0b2d8ad1eea68b49c6a37772fdb1ae6016da9ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
flutter_image_compress_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress_macos
|
||||||
|
sha256: "0d2a842d2e544828fb32bda16dcfccb3149107df568898a4936d78232e486847"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
flutter_image_compress_ohos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress_ohos
|
||||||
|
sha256: "1491bb7bcfdf59e3b127c263c116cfbf8ed3afade6e7fa691d9622e838ed7e48"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.3+1"
|
||||||
|
flutter_image_compress_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress_platform_interface
|
||||||
|
sha256: bbefb7967bda565004fdabdb3300dcbfb40c7ef8402675d23206e5bddc358178
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
flutter_image_compress_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_image_compress_web
|
||||||
|
sha256: "91cc58e091a1e09c7683d216d579e2b964119a8527fc43b64dfdb00f1acc94ec"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.5+1"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -86,6 +235,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.35"
|
||||||
|
flutter_secure_storage:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage
|
||||||
|
sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.0"
|
||||||
|
flutter_secure_storage_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_darwin
|
||||||
|
sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.0"
|
||||||
|
flutter_secure_storage_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_linux
|
||||||
|
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
flutter_secure_storage_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_platform_interface
|
||||||
|
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
|
flutter_secure_storage_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_web
|
||||||
|
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
|
flutter_secure_storage_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_windows
|
||||||
|
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.2"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -96,6 +301,128 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
fuchsia_remote_debug_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
|
http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
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:
|
||||||
|
name: jni
|
||||||
|
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
|
jni_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_flutter
|
||||||
|
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
|
jni_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_util
|
||||||
|
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -128,6 +455,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.1.0"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -152,6 +487,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.18.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.5.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.2.1"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -160,6 +535,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.6"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -200,6 +599,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
process:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: process
|
||||||
|
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.6"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
record_use:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: record_use
|
||||||
|
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -293,6 +716,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
sync_http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sync_http
|
||||||
|
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -309,6 +740,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.11"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
uuid:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.6.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -333,6 +780,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
webdriver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webdriver
|
||||||
|
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.4.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -341,6 +804,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.4"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.12.2 <4.0.0"
|
dart: ">=3.12.2 <4.0.0"
|
||||||
flutter: ">=3.44.0"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@@ -35,10 +35,23 @@ dependencies:
|
|||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.4
|
||||||
|
dio: ^5.11.1
|
||||||
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
# T3-14 compose 真链路桌面实测入口(integration_test/,环境变量门控)。
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
|
# 定时冲刷/退避测试的假时钟驱动(flutter_test 传递依赖显式声明)。
|
||||||
|
fake_async: ^1.3.3
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
|
|||||||
Executable
+104
@@ -0,0 +1,104 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# check-secrets.sh —— 凭证防泄漏检查(ADR-021,规范见 patbond-doc docs/development/git-workflow.md)
|
||||||
|
#
|
||||||
|
# 规则单一来源:本地 pre-commit 与 CI 兜底跑的是同一个脚本、同一张规则表。
|
||||||
|
# 三仓(patbond-api / patbond-flutter / patbond-doc)各存一份同构副本,改规则时三仓同步。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# sh scripts/check-secrets.sh --staged # pre-commit:扫暂存区内容(经 scripts/hooks/pre-commit 调用)
|
||||||
|
# sh scripts/check-secrets.sh --all # CI 兜底 / 手动自查:扫全部已跟踪文件(缺省模式)
|
||||||
|
# sh scripts/check-secrets.sh <文件...> # 扫指定文件
|
||||||
|
#
|
||||||
|
# 拦下真实凭证时的第一动作:去云控制台轮换/禁用该密钥,然后才是清理提交。
|
||||||
|
set -u
|
||||||
|
|
||||||
|
mode="${1:---all}"
|
||||||
|
|
||||||
|
# 允许清单:行内出现任一形态即放行(${} 注入、占位值、明显示例值)
|
||||||
|
ALLOW='\$\{[^}]*\}|\{\{[^}]*\}\}|changeme|change[-_]me|your[-_][a-zA-Z0-9_-]+|<[a-zA-Z0-9 ,_.-]+>|placeholder|example|sample|dummy|fake|redacted|\*\*\*'
|
||||||
|
|
||||||
|
# 内容扫描跳过:本脚本与 hook 自身(含规则文本,非凭证)
|
||||||
|
SKIP_PATHS='(^|/)scripts/(check-secrets\.sh|hooks/pre-commit)$'
|
||||||
|
|
||||||
|
# 文件名黑名单:凭证载体文件本体禁止入库(.sample/.example 除外)
|
||||||
|
DENY_NAME='(^|/)\.env(\.[^/]+)?$|(^|/)credentials[^/]*$|[Aa]ccess[Kk]eys?[^/]*\.csv$|(^|/)rootkey\.csv$'
|
||||||
|
DENY_NAME_OK='\.(sample|example)$'
|
||||||
|
|
||||||
|
# 规则表:ID<TAB>大小写旗标(i=忽略大小写,-=敏感)<TAB>文件范围ERE(-=全部文件)<TAB>行模式ERE
|
||||||
|
RULES=$(cat <<'EOF'
|
||||||
|
AK-AWS - - AKIA[0-9A-Z]{16}
|
||||||
|
AK-QCLOUD - - AKID[0-9A-Za-z]{16,}
|
||||||
|
AK-ALIYUN - - LTAI[0-9A-Za-z]{12,}
|
||||||
|
MINIO-DEFAULT i - minio[-_.]?admin
|
||||||
|
PRIVATE-KEY - - ^[[:space:]]*-----BEGIN [A-Z ]*PRIVATE KEY-----[[:space:]]*$
|
||||||
|
KEY-ASSIGN i - (access[-_]?key(_?id)?|secret[-_]?(access[-_]?)?key)["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
|
||||||
|
JWT-SECRET i - (jwt[-_.]?secret|signing[-_]?key|token[-_]?secret|hmac[-_]?(key|secret))["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
|
||||||
|
DB-PASSWORD i \.(ya?ml|properties|toml|conf|ini)(\.sample|\.example)?$ (password|passwd|pwd)["']?[[:space:]]*[:=][[:space:]]*["']?[^[:space:]"'$]{6,}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
--staged)
|
||||||
|
files=$(git diff --cached --name-only --diff-filter=ACM)
|
||||||
|
src=index
|
||||||
|
;;
|
||||||
|
--all)
|
||||||
|
files=$(git ls-files)
|
||||||
|
src=worktree
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "用法: $0 [--staged|--all|<文件...>]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
files=$(printf '%s\n' "$@")
|
||||||
|
src=worktree
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[ -n "$files" ] || exit 0
|
||||||
|
|
||||||
|
tmp=$(mktemp) || exit 2
|
||||||
|
viol=$(mktemp) || exit 2
|
||||||
|
trap 'rm -f "$tmp" "$viol"' EXIT
|
||||||
|
|
||||||
|
# 第一道:文件名黑名单
|
||||||
|
printf '%s\n' "$files" | grep -E "$DENY_NAME" | grep -vE "$DENY_NAME_OK" |
|
||||||
|
sed 's/^/[NAME-DENY] /' >>"$viol" || true
|
||||||
|
|
||||||
|
# 第二道:逐文件逐规则内容扫描(二进制文件经 grep -I 自然跳过)
|
||||||
|
IFS='
|
||||||
|
'
|
||||||
|
for f in $files; do
|
||||||
|
printf '%s' "$f" | grep -qE "$SKIP_PATHS" && continue
|
||||||
|
if [ "$src" = index ]; then
|
||||||
|
git show ":$f" >"$tmp" 2>/dev/null || continue
|
||||||
|
else
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
cat -- "$f" >"$tmp"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$RULES" | while IFS="$(printf '\t')" read -r id flag scope pat; do
|
||||||
|
[ -n "$id" ] || continue
|
||||||
|
if [ "$scope" != "-" ]; then
|
||||||
|
printf '%s' "$f" | grep -qE "$scope" || continue
|
||||||
|
fi
|
||||||
|
ci=""
|
||||||
|
[ "$flag" = "i" ] && ci="-i"
|
||||||
|
grep -InE $ci -e "$pat" "$tmp" 2>/dev/null | grep -viE "$ALLOW" |
|
||||||
|
sed "s|^|[$id] $f:|" >>"$viol" || true
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -s "$viol" ]; then
|
||||||
|
echo "凭证防泄漏检查未通过(ADR-021)——以下内容疑似真实凭证:" >&2
|
||||||
|
cat "$viol" >&2
|
||||||
|
cat >&2 <<'MSG'
|
||||||
|
处置:
|
||||||
|
1. 若是真实凭证:先去云控制台轮换/禁用该密钥,再从提交中移除;
|
||||||
|
2. 若是误报:改用 ${} 注入或占位值(changeme / your-xxx / <占位>),
|
||||||
|
或与团队确认后调整三仓同构的 scripts/check-secrets.sh 规则表。
|
||||||
|
敏感信息只允许存在于被 gitignore 的文件或 .sample 占位中(git-workflow.md)。
|
||||||
|
MSG
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# pre-commit —— 凭证防泄漏(ADR-021)。启用(每人每仓一次):
|
||||||
|
# git config core.hooksPath scripts/hooks
|
||||||
|
# 注意:core.hooksPath 会整体接管 hooks 目录;本仓无其他自定义 hook。
|
||||||
|
repo_root=$(git rev-parse --show-toplevel) || exit 1
|
||||||
|
exec sh "$repo_root/scripts/check-secrets.sh" --staged
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
Map<String, dynamic> event(int seq) => {
|
||||||
|
'eventId': 'id-$seq',
|
||||||
|
'eventName': 'auth_login_succeeded',
|
||||||
|
'seq': seq,
|
||||||
|
};
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AnalyticsEventStore', () {
|
||||||
|
test('事件持久化后新实例冷启动可恢复(顺序保持旧 → 新)', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final store = AnalyticsEventStore();
|
||||||
|
await store.restore();
|
||||||
|
for (var i = 0; i < 45; i++) {
|
||||||
|
await store.add(event(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
final reborn = AnalyticsEventStore();
|
||||||
|
await reborn.restore();
|
||||||
|
|
||||||
|
expect(reborn.length, 45);
|
||||||
|
expect(reborn.events.first['seq'], 0);
|
||||||
|
expect(reborn.events.last['seq'], 44);
|
||||||
|
// 45 条按容量 20 分段应为 3 段
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final index =
|
||||||
|
jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!) as List;
|
||||||
|
expect(index.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('超过 500 条总上限时丢最旧整段', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final store = AnalyticsEventStore();
|
||||||
|
await store.restore();
|
||||||
|
for (var i = 0; i < 520; i++) {
|
||||||
|
await store.add(event(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 501 条触发淘汰最旧一段(20 条),此后回落到上限内
|
||||||
|
expect(store.length, 500);
|
||||||
|
expect(store.events.first['seq'], 20);
|
||||||
|
expect(store.events.last['seq'], 519);
|
||||||
|
expect(store.droppedCount, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('损坏段丢弃不崩溃,其余段正常恢复且索引被清理', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
AnalyticsEventStore.segIndexKey: jsonEncode(['bad', 'good']),
|
||||||
|
'${AnalyticsEventStore.segKeyPrefix}bad': '{not-valid-json[[',
|
||||||
|
'${AnalyticsEventStore.segKeyPrefix}good': jsonEncode([event(7)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
final store = AnalyticsEventStore();
|
||||||
|
await store.restore();
|
||||||
|
|
||||||
|
expect(store.length, 1);
|
||||||
|
expect(store.events.single['seq'], 7);
|
||||||
|
expect(store.droppedCount, 1);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getString('${AnalyticsEventStore.segKeyPrefix}bad'), isNull);
|
||||||
|
expect(jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!), [
|
||||||
|
'good',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('段索引本身损坏时清扫孤儿段、从空队列重建', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
AnalyticsEventStore.segIndexKey: 'garbage-not-json',
|
||||||
|
'${AnalyticsEventStore.segKeyPrefix}orphan': jsonEncode([event(1)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
final store = AnalyticsEventStore();
|
||||||
|
await store.restore();
|
||||||
|
|
||||||
|
expect(store.length, 0);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(
|
||||||
|
prefs.getString('${AnalyticsEventStore.segKeyPrefix}orphan'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
// 可继续正常写入
|
||||||
|
await store.add(event(2));
|
||||||
|
expect(store.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('takeBatch 封段:在途批次不受新事件影响,删段后新事件保留', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final store = AnalyticsEventStore();
|
||||||
|
await store.restore();
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
await store.add(event(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
final batch = store.takeBatch(50);
|
||||||
|
// 模拟上传在途期间进入的新事件
|
||||||
|
await store.add(event(99));
|
||||||
|
|
||||||
|
expect(batch.events.length, 5);
|
||||||
|
await store.removeSegments(batch.segmentIds);
|
||||||
|
expect(store.length, 1);
|
||||||
|
expect(store.events.single['seq'], 99);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('takeBatch 按段拼批不超过单批上限', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final store = AnalyticsEventStore();
|
||||||
|
await store.restore();
|
||||||
|
for (var i = 0; i < 60; i++) {
|
||||||
|
await store.add(event(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 段容量 20、单批上限 50 → 每批最多 2 整段(40 条)
|
||||||
|
final first = store.takeBatch(50);
|
||||||
|
expect(first.events.length, 40);
|
||||||
|
await store.removeSegments(first.segmentIds);
|
||||||
|
final second = store.takeBatch(50);
|
||||||
|
expect(second.events.length, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('上传成功删段后 shared_preferences 无残留', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final store = AnalyticsEventStore();
|
||||||
|
await store.restore();
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
await store.add(event(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
final batch = store.takeBatch(50);
|
||||||
|
await store.removeSegments(batch.segmentIds);
|
||||||
|
|
||||||
|
expect(store.length, 0);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(
|
||||||
|
prefs.getKeys().where(
|
||||||
|
(key) => key.startsWith(AnalyticsEventStore.segKeyPrefix),
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未调用 restore(持久化不可用)时降级为纯内存队列', () async {
|
||||||
|
final store = AnalyticsEventStore();
|
||||||
|
await store.add(event(1));
|
||||||
|
expect(store.length, 1);
|
||||||
|
final batch = store.takeBatch(50);
|
||||||
|
await store.removeSegments(batch.segmentIds);
|
||||||
|
expect(store.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_service.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// 服务级持久化队列联动测试(13 号规范 §3.3/§3.4):
|
||||||
|
/// 本地 HttpServer 模拟 /api/v1/events 的 202 / 400 响应。
|
||||||
|
void main() {
|
||||||
|
Future<(HttpServer, List<int>)> startServer(int statusCode) async {
|
||||||
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
final batchSizes = <int>[];
|
||||||
|
server.listen((request) async {
|
||||||
|
final body =
|
||||||
|
jsonDecode(await utf8.decoder.bind(request).join())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
batchSizes.add((body['events'] as List).length);
|
||||||
|
request.response.statusCode = statusCode;
|
||||||
|
await request.response.close();
|
||||||
|
});
|
||||||
|
return (server, batchSizes);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnalyticsService buildService(String apiBaseUrl, AnalyticsEventStore store) {
|
||||||
|
return AnalyticsService(
|
||||||
|
apiBaseUrl: apiBaseUrl,
|
||||||
|
getAccessToken: null,
|
||||||
|
getSessionId: () => 'session-x',
|
||||||
|
store: store,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('AnalyticsService 持久化队列', () {
|
||||||
|
test('满 20 条冲刷、202 受理后清空持久化段', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final (server, batchSizes) = await startServer(202);
|
||||||
|
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, isEmpty);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(
|
||||||
|
prefs.getKeys().where(
|
||||||
|
(key) => key.startsWith(AnalyticsEventStore.segKeyPrefix),
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flushNow 冲刷不满 20 条的持久化队列', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final (server, batchSizes) = await startServer(202);
|
||||||
|
addTearDown(() => server.close(force: true));
|
||||||
|
final store = AnalyticsEventStore();
|
||||||
|
await store.restore();
|
||||||
|
final service = buildService('http://127.0.0.1:${server.port}', store);
|
||||||
|
|
||||||
|
await service.trackEvent('auth_login_succeeded');
|
||||||
|
await service.trackEvent('auth_logout');
|
||||||
|
await service.flushNow();
|
||||||
|
|
||||||
|
expect(batchSizes, [2]);
|
||||||
|
expect(service.pendingEvents, isEmpty);
|
||||||
|
expect(store.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('上传失败事件持久化,冷启动恢复后自动重传', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
// 第一个进程:端口不可达,20 条触发冲刷失败,事件留在持久化队列
|
||||||
|
final storeA = AnalyticsEventStore();
|
||||||
|
await storeA.restore();
|
||||||
|
final serviceA = buildService('http://127.0.0.1:1', storeA);
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
await serviceA.trackEvent('auth_login_succeeded', {'attemptSeq': i});
|
||||||
|
}
|
||||||
|
expect(serviceA.pendingEvents.length, 20);
|
||||||
|
|
||||||
|
// 模拟冷启动:新实例 restore 恢复并冲刷到可用服务端
|
||||||
|
final (server, batchSizes) = await startServer(202);
|
||||||
|
addTearDown(() => server.close(force: true));
|
||||||
|
final storeB = AnalyticsEventStore();
|
||||||
|
final serviceB = buildService('http://127.0.0.1:${server.port}', storeB);
|
||||||
|
await serviceB.restore();
|
||||||
|
|
||||||
|
expect(batchSizes, [20]);
|
||||||
|
expect(serviceB.pendingEvents, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4xx 永久拒绝直接删段丢弃(毒丸防护),计入丢弃诊断数', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final (server, batchSizes) = await startServer(400);
|
||||||
|
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, isEmpty);
|
||||||
|
expect(store.droppedCount, 20);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(
|
||||||
|
prefs.getKeys().where(
|
||||||
|
(key) => key.startsWith(AnalyticsEventStore.segKeyPrefix),
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('积压超过单批上限时按 50 条分批循环上传', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final (server, batchSizes) = await startServer(202);
|
||||||
|
addTearDown(() => server.close(force: true));
|
||||||
|
final store = AnalyticsEventStore();
|
||||||
|
await store.restore();
|
||||||
|
// 直接预置 60 条积压(绕过 trackEvent 的满 20 冲刷触发)
|
||||||
|
for (var i = 0; i < 60; i++) {
|
||||||
|
await store.add({'eventId': 'id-$i', 'eventName': 'page_viewed'});
|
||||||
|
}
|
||||||
|
final service = buildService('http://127.0.0.1:${server.port}', store);
|
||||||
|
|
||||||
|
await service.flushNow();
|
||||||
|
|
||||||
|
// 段容量 20、单批上限 50 → 40 + 20 两批
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_route_observer.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AnalyticsRouteObserver', () {
|
||||||
|
testWidgets('push 路由报 page_viewed', (tester) async {
|
||||||
|
final events = <Map<String, dynamic>>[];
|
||||||
|
final tracker = PageViewTracker((name, [props]) async {
|
||||||
|
events.add({'event': name, ...?props});
|
||||||
|
});
|
||||||
|
final observer = AnalyticsRouteObserver(tracker: tracker);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
navigatorObservers: [observer],
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
settings: RouteSettings(
|
||||||
|
name: AnalyticsPageName.login.pageName,
|
||||||
|
),
|
||||||
|
builder: (_) => const Placeholder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Go'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Go'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(events, hasLength(1));
|
||||||
|
expect(events.first['event'], 'page_viewed');
|
||||||
|
expect(events.first['pageName'], 'login');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('push 两页 referrer 链正确,pop 返回补报前一页', (tester) async {
|
||||||
|
final events = <Map<String, dynamic>>[];
|
||||||
|
final tracker = PageViewTracker((name, [props]) async {
|
||||||
|
events.add({'event': name, ...?props});
|
||||||
|
});
|
||||||
|
final observer = AnalyticsRouteObserver(tracker: tracker);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
navigatorObservers: [observer],
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
settings: RouteSettings(
|
||||||
|
name: AnalyticsPageName.login.pageName,
|
||||||
|
),
|
||||||
|
builder: (loginContext) => ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(loginContext).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
settings: RouteSettings(
|
||||||
|
name: AnalyticsPageName.register.pageName,
|
||||||
|
),
|
||||||
|
builder: (registerContext) => ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(registerContext).pop(),
|
||||||
|
child: const Text('Back'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Next'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Go'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Go'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// push 两页:两条事件,referrer 链正确(06 §5.2 验收 5)。
|
||||||
|
expect(events.map((e) => e['pageName']), ['login', 'register']);
|
||||||
|
expect(events[0].containsKey('referrer'), isFalse);
|
||||||
|
expect(events[1]['referrer'], 'login');
|
||||||
|
|
||||||
|
// pop 返回:didPop 补报重新曝光的前一页,referrer 为弹出页。
|
||||||
|
await tester.tap(find.text('Back'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(events, hasLength(3));
|
||||||
|
expect(events[2]['pageName'], 'login');
|
||||||
|
expect(events[2]['referrer'], 'register');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pop 回无名根路由经 resolveRootPage 补报', (tester) async {
|
||||||
|
final events = <Map<String, dynamic>>[];
|
||||||
|
final tracker = PageViewTracker((name, [props]) async {
|
||||||
|
events.add({'event': name, ...?props});
|
||||||
|
});
|
||||||
|
final observer = AnalyticsRouteObserver(
|
||||||
|
tracker: tracker,
|
||||||
|
resolveRootPage: () => AnalyticsPageName.home,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
navigatorObservers: [observer],
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
settings: RouteSettings(
|
||||||
|
name: AnalyticsPageName.profile.pageName,
|
||||||
|
),
|
||||||
|
builder: (innerContext) => ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(innerContext).pop(),
|
||||||
|
child: const Text('Back'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Go'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Go'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Back'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(events.map((e) => e['pageName']), ['profile', 'home']);
|
||||||
|
expect(events[1]['referrer'], 'profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('未在枚举的路由名不上报', (tester) async {
|
||||||
|
final events = <Map<String, dynamic>>[];
|
||||||
|
final tracker = PageViewTracker((name, [props]) async {
|
||||||
|
events.add({'event': name, ...?props});
|
||||||
|
});
|
||||||
|
final observer = AnalyticsRouteObserver(tracker: tracker);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
navigatorObservers: [observer],
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
settings: const RouteSettings(name: '/debug/unknown'),
|
||||||
|
builder: (_) => const Placeholder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Go'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Go'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(events, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('dialog 不上报(PopupRoute 不是 PageRoute)', (tester) async {
|
||||||
|
final events = <Map<String, dynamic>>[];
|
||||||
|
final tracker = PageViewTracker((name, [props]) async {
|
||||||
|
events.add({'event': name, ...?props});
|
||||||
|
});
|
||||||
|
final observer = AnalyticsRouteObserver(tracker: tracker);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
navigatorObservers: [observer],
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => ElevatedButton(
|
||||||
|
onPressed: () => showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const AlertDialog(title: Text('Dialog')),
|
||||||
|
),
|
||||||
|
child: const Text('Show'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Show'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(events, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PageViewTracker', () {
|
||||||
|
test('连续相同页面去重(Tab 重复点选)', () {
|
||||||
|
final events = <Map<String, dynamic>>[];
|
||||||
|
final tracker = PageViewTracker((name, [props]) async {
|
||||||
|
events.add({'event': name, ...?props});
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.report(AnalyticsPageName.home);
|
||||||
|
tracker.report(AnalyticsPageName.home);
|
||||||
|
tracker.report(AnalyticsPageName.profile);
|
||||||
|
tracker.report(AnalyticsPageName.home);
|
||||||
|
|
||||||
|
expect(events, hasLength(3));
|
||||||
|
expect(events.map((e) => e['pageName']), ['home', 'profile', 'home']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('referrer 链跨机制连贯(首页无 referrer)', () {
|
||||||
|
final events = <Map<String, dynamic>>[];
|
||||||
|
final tracker = PageViewTracker((name, [props]) async {
|
||||||
|
events.add({'event': name, ...?props});
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.report(AnalyticsPageName.home);
|
||||||
|
tracker.report(AnalyticsPageName.login);
|
||||||
|
tracker.report(AnalyticsPageName.profile);
|
||||||
|
|
||||||
|
expect(events[0].containsKey('referrer'), isFalse);
|
||||||
|
expect(events[1]['referrer'], 'home');
|
||||||
|
expect(events[2]['referrer'], 'login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reportTab 记录当前 Tab', () {
|
||||||
|
final events = <Map<String, dynamic>>[];
|
||||||
|
final tracker = PageViewTracker((name, [props]) async {
|
||||||
|
events.add({'event': name, ...?props});
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.reportTab(AnalyticsPageName.petArchive);
|
||||||
|
expect(tracker.currentTab, AnalyticsPageName.petArchive);
|
||||||
|
expect(events.single['pageName'], 'pet_archive');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final uuidV7 = RegExp(
|
||||||
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||||
|
);
|
||||||
|
|
||||||
|
AnalyticsService buildService({
|
||||||
|
String Function()? getSessionId,
|
||||||
|
String? anonymousId,
|
||||||
|
}) {
|
||||||
|
var counter = 0;
|
||||||
|
return AnalyticsService(
|
||||||
|
apiBaseUrl: 'http://test',
|
||||||
|
getAccessToken: null,
|
||||||
|
getSessionId: getSessionId ?? () => 'session-${counter++}',
|
||||||
|
anonymousId: anonymousId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('AnalyticsService', () {
|
||||||
|
test('tracks event with required fields', () async {
|
||||||
|
final service = buildService(getSessionId: () => 'session-a');
|
||||||
|
|
||||||
|
await service.trackEvent('auth_login_succeeded', {
|
||||||
|
'identifierType': 'username',
|
||||||
|
'durationMs': 123,
|
||||||
|
});
|
||||||
|
|
||||||
|
final event = service.pendingEvents.single;
|
||||||
|
expect(event['eventName'], 'auth_login_succeeded');
|
||||||
|
expect(event['eventVersion'], 1);
|
||||||
|
expect(event['sessionId'], 'session-a');
|
||||||
|
expect(event['anonymousId'], isNotEmpty);
|
||||||
|
expect(event['clientTs'], isNotEmpty);
|
||||||
|
expect(event['appVersion'], isNotEmpty);
|
||||||
|
expect(event['osVersion'], isNotEmpty);
|
||||||
|
expect(event['props'], {'identifierType': 'username', 'durationMs': 123});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eventId 为 UUIDv7 且逐事件唯一', () async {
|
||||||
|
final service = buildService();
|
||||||
|
|
||||||
|
await service.trackEvent('auth_login_succeeded');
|
||||||
|
await service.trackEvent('auth_logout');
|
||||||
|
|
||||||
|
final ids = service.pendingEvents
|
||||||
|
.map((event) => event['eventId'] as String)
|
||||||
|
.toList();
|
||||||
|
expect(ids[0], matches(uuidV7));
|
||||||
|
expect(ids[1], matches(uuidV7));
|
||||||
|
expect(ids[0], isNot(ids[1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同一 tracker 下多事件 sessionId 相同,不再每事件生成', () async {
|
||||||
|
final service = buildService(getSessionId: () => 'tracker-session');
|
||||||
|
|
||||||
|
await service.trackEvent('auth_login_succeeded');
|
||||||
|
await service.trackEvent('page_viewed', {'pageName': 'home'});
|
||||||
|
await service.trackEvent('auth_logout');
|
||||||
|
|
||||||
|
final sessionIds = service.pendingEvents
|
||||||
|
.map((event) => event['sessionId'])
|
||||||
|
.toSet();
|
||||||
|
expect(sessionIds, {'tracker-session'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('appVersion 可注入更新(不再硬编码)', () async {
|
||||||
|
final service = buildService();
|
||||||
|
|
||||||
|
service.setAppVersion('2.3.4+56');
|
||||||
|
await service.trackEvent('auth_login_succeeded');
|
||||||
|
|
||||||
|
expect(service.pendingEvents.single['appVersion'], '2.3.4+56');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects events with forbidden field patterns', () async {
|
||||||
|
final service = buildService();
|
||||||
|
|
||||||
|
// Forbidden field triggers local rejection (no throw, silent drop).
|
||||||
|
await service.trackEvent('auth_login_succeeded', {
|
||||||
|
'userPassword': 'leak', // Forbidden pattern
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.pendingEvents, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('identify sets userId on subsequent events', () async {
|
||||||
|
final service = buildService();
|
||||||
|
|
||||||
|
service.identify('user-123');
|
||||||
|
await service.trackEvent('auth_login_succeeded');
|
||||||
|
|
||||||
|
expect(service.pendingEvents.single['userId'], 'user-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset clears userId but keeps anonymousId', () async {
|
||||||
|
final service = buildService(anonymousId: 'anon-123');
|
||||||
|
|
||||||
|
service.identify('user-123');
|
||||||
|
service.reset();
|
||||||
|
await service.trackEvent('auth_logout');
|
||||||
|
|
||||||
|
final event = service.pendingEvents.single;
|
||||||
|
expect(event.containsKey('userId'), isFalse);
|
||||||
|
expect(event['anonymousId'], 'anon-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('上传失败批次重回队列而非整批丢弃', () async {
|
||||||
|
// apiBaseUrl 指向不可达端口:满 20 条触发 flush 必然失败。
|
||||||
|
final service = AnalyticsService(
|
||||||
|
apiBaseUrl: 'http://127.0.0.1:1',
|
||||||
|
getAccessToken: null,
|
||||||
|
getSessionId: () => 's',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
await service.trackEvent('auth_login_succeeded', {'attemptSeq': i});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/session_tracker.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final uuidV7 = RegExp(
|
||||||
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||||
|
);
|
||||||
|
|
||||||
|
group('SessionTracker', () {
|
||||||
|
test('冷启动生成 UUIDv7 格式的 sessionId', () {
|
||||||
|
final tracker = SessionTracker();
|
||||||
|
expect(tracker.sessionId, matches(uuidV7));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('短后台(≤30 分钟)resume 后沿用原 sessionId', () {
|
||||||
|
var clock = DateTime.utc(2026, 9, 7, 10);
|
||||||
|
final tracker = SessionTracker(now: () => clock);
|
||||||
|
final original = tracker.sessionId;
|
||||||
|
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||||
|
clock = clock.add(const Duration(minutes: 30));
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||||
|
|
||||||
|
expect(tracker.sessionId, original);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('长后台(>30 分钟)resume 后生成新 sessionId', () {
|
||||||
|
var clock = DateTime.utc(2026, 9, 7, 10);
|
||||||
|
final tracker = SessionTracker(now: () => clock);
|
||||||
|
final original = tracker.sessionId;
|
||||||
|
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||||
|
clock = clock.add(const Duration(minutes: 31));
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||||
|
|
||||||
|
expect(tracker.sessionId, isNot(original));
|
||||||
|
expect(tracker.sessionId, matches(uuidV7));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('真实生命周期级联下退后台时刻不被 inactive 覆盖', () {
|
||||||
|
// 前台恢复时状态机级联 paused → hidden → inactive → resumed,
|
||||||
|
// 若 inactive 覆盖记时,31 分钟长后台会被误判为 0 分钟。
|
||||||
|
var clock = DateTime.utc(2026, 9, 7, 10);
|
||||||
|
final tracker = SessionTracker(now: () => clock);
|
||||||
|
final original = tracker.sessionId;
|
||||||
|
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||||
|
clock = clock.add(const Duration(minutes: 31));
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||||
|
|
||||||
|
expect(tracker.sessionId, isNot(original));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('连续多次短后台幂等,仅超阈值才换新', () {
|
||||||
|
var clock = DateTime.utc(2026, 9, 7, 10);
|
||||||
|
final tracker = SessionTracker(now: () => clock);
|
||||||
|
final original = tracker.sessionId;
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||||
|
clock = clock.add(const Duration(minutes: 10));
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||||
|
expect(tracker.sessionId, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||||
|
clock = clock.add(const Duration(minutes: 35));
|
||||||
|
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,111 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.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 '../../helpers/auth_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late SessionManager session;
|
||||||
|
late Dio dio;
|
||||||
|
late FakeHttpAdapter adapter;
|
||||||
|
late TokenRefresher refresher;
|
||||||
|
|
||||||
|
Future<void> setUpWith(
|
||||||
|
Future<ResponseBody> Function(RequestOptions) handler, {
|
||||||
|
bool seedTokens = true,
|
||||||
|
}) async {
|
||||||
|
session = SessionManager(store: InMemoryTokenStore());
|
||||||
|
if (seedTokens) {
|
||||||
|
await session.updateTokens(
|
||||||
|
sampleTokens(access: 'old-access', refresh: 'old-refresh'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dio = Dio(BaseOptions(validateStatus: (_) => true));
|
||||||
|
adapter = FakeHttpAdapter(handler);
|
||||||
|
dio.httpClientAdapter = adapter;
|
||||||
|
refresher = TokenRefresher(dio: dio, session: session);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('并发触发只发出一次刷新请求(单飞),并轮换 token', () async {
|
||||||
|
await setUpWith((options) async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
return jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope(tokenDataJson(access: 'new-access', refresh: 'new-refresh')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Future.wait([refresher.refresh(), refresher.refresh()]);
|
||||||
|
|
||||||
|
expect(adapter.requests, hasLength(1));
|
||||||
|
expect(adapter.requests.single.path, '/api/v1/auth/refresh');
|
||||||
|
expect(adapter.requests.single.data, {'refreshToken': 'old-refresh'});
|
||||||
|
expect(session.accessToken, 'new-access');
|
||||||
|
expect(session.refreshToken, 'new-refresh');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40102 清除会话并抛 SessionExpiredException', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async =>
|
||||||
|
jsonResponse(401, errorEnvelope(40102, 'refresh token 已失效')),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
refresher.refresh(),
|
||||||
|
throwsA(isA<SessionExpiredException>()),
|
||||||
|
);
|
||||||
|
expect(session.accessToken, isNull);
|
||||||
|
expect(session.refreshToken, isNull);
|
||||||
|
expect(session.status, AuthStatus.unauthenticated);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('网络失败不清除本地 token,抛 ApiNetworkException', () async {
|
||||||
|
await setUpWith((options) async {
|
||||||
|
throw DioException(
|
||||||
|
requestOptions: options,
|
||||||
|
type: DioExceptionType.connectionTimeout,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectLater(refresher.refresh(), throwsA(isA<ApiNetworkException>()));
|
||||||
|
expect(session.refreshToken, 'old-refresh');
|
||||||
|
expect(session.accessToken, 'old-access');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('上一次刷新结束后单飞复位,可再次刷新', () async {
|
||||||
|
var calls = 0;
|
||||||
|
await setUpWith((options) async {
|
||||||
|
calls += 1;
|
||||||
|
if (calls == 1) {
|
||||||
|
throw DioException(
|
||||||
|
requestOptions: options,
|
||||||
|
type: DioExceptionType.connectionError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope(tokenDataJson(access: 'a2', refresh: 'r2')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectLater(refresher.refresh(), throwsA(isA<ApiNetworkException>()));
|
||||||
|
await refresher.refresh();
|
||||||
|
expect(calls, 2);
|
||||||
|
expect(session.refreshToken, 'r2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('本地无 refresh token 时直接判会话失效', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(200, okEnvelope(tokenDataJson())),
|
||||||
|
seedTokens: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
refresher.refresh(),
|
||||||
|
throwsA(isA<SessionExpiredException>()),
|
||||||
|
);
|
||||||
|
expect(adapter.requests, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,45 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('渲染插画 / 标题 / 说明 / CTA,点击 CTA 回调', (tester) async {
|
||||||
|
var taps = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: EmptyStateIllustration(
|
||||||
|
icon: Icons.pets,
|
||||||
|
title: '还没有宠物档案',
|
||||||
|
description: '添加毛孩子,开始记录 TA 的健康点滴',
|
||||||
|
ctaLabel: '添加宠物',
|
||||||
|
onCtaPressed: () => taps++,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.pets), findsOneWidget);
|
||||||
|
expect(find.text('还没有宠物档案'), findsOneWidget);
|
||||||
|
expect(find.text('添加毛孩子,开始记录 TA 的健康点滴'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('添加宠物'));
|
||||||
|
expect(taps, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('无 CTA / 无说明时不渲染对应区块', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: EmptyStateIllustration(
|
||||||
|
icon: Icons.event_note_outlined,
|
||||||
|
title: '暂无体重记录',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('暂无体重记录'), findsOneWidget);
|
||||||
|
expect(find.byType(FilledButton), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,72 @@
|
|||||||
|
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/pet_avatar.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Future<void> pump(WidgetTester tester, Widget child) {
|
||||||
|
return tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(body: Center(child: child)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('四档尺寸与规格一致(96/64/44/32)', (tester) async {
|
||||||
|
expect(PetAvatarSize.xl.dimension, 96);
|
||||||
|
expect(PetAvatarSize.lg.dimension, 64);
|
||||||
|
expect(PetAvatarSize.md.dimension, 44);
|
||||||
|
expect(PetAvatarSize.sm.dimension, 32);
|
||||||
|
|
||||||
|
await pump(tester, const PetAvatar(size: PetAvatarSize.xl));
|
||||||
|
final size = tester.getSize(find.byType(PetAvatar));
|
||||||
|
expect(size.width, 96);
|
||||||
|
expect(size.height, 96);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('无 url 渲染本地占位形态(ADR-010:pets 图标)', (tester) async {
|
||||||
|
await pump(tester, const PetAvatar(size: PetAvatarSize.lg));
|
||||||
|
final icon = tester.widget<Icon>(find.byIcon(Icons.pets));
|
||||||
|
expect(icon.color, AppColors.muted);
|
||||||
|
expect(find.byType(Image), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('xl 档编辑徽标为 primaryStrong 底白图标(无障碍修订)', (tester) async {
|
||||||
|
await pump(
|
||||||
|
tester,
|
||||||
|
const PetAvatar(size: PetAvatarSize.xl, showEditBadge: true),
|
||||||
|
);
|
||||||
|
final badgeIcon = tester.widget<Icon>(find.byIcon(Icons.edit));
|
||||||
|
expect(badgeIcon.color, Colors.white);
|
||||||
|
final badge = tester.widget<Container>(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.byIcon(Icons.edit),
|
||||||
|
matching: find.byType(Container),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final decoration = badge.decoration as BoxDecoration;
|
||||||
|
expect(decoration.color, AppColors.primaryStrong);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('sm/md 档不渲染编辑徽标', (tester) async {
|
||||||
|
await pump(
|
||||||
|
tester,
|
||||||
|
const PetAvatar(size: PetAvatarSize.sm, showEditBadge: true),
|
||||||
|
);
|
||||||
|
expect(find.byIcon(Icons.edit), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('onTap 生效且禁用态不响应', (tester) async {
|
||||||
|
var taps = 0;
|
||||||
|
await pump(tester, PetAvatar(size: PetAvatarSize.lg, onTap: () => taps++));
|
||||||
|
await tester.tap(find.byType(PetAvatar));
|
||||||
|
expect(taps, 1);
|
||||||
|
|
||||||
|
await pump(
|
||||||
|
tester,
|
||||||
|
PetAvatar(size: PetAvatarSize.lg, enabled: false, onTap: () => taps++),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byType(PetAvatar));
|
||||||
|
expect(taps, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user