1 Commits

Author SHA1 Message Date
lixi f501a959f3 修复:埋点接线与三处偏差(sessionId/eventId/设备信息)
CI / flutter-gates (push) Successful in 1m9s
- 生产接线修复:app.dart 组装时传 analytics 实例给 ApiAuthRepository(_buildRepository),修复 M1 遗留的「生产环境 _analytics 恒为 null、挂接点空转」问题
- sessionId 生命周期:新建 SessionTracker (WidgetsBindingObserver),冷启动生成 UUIDv7、后台超 30 分钟换新、未超阈值沿用原值,不再每事件随机生成
- eventId 改 UUIDv7:对齐 13 号规范(uuid 包已在依赖,直接用 v7()),保留插入时间局部性
- appVersion 动态注入:package_info_plus(新增依赖)异步读取后 setAppVersion,不再硬编码 '1.0.0+1'
- osVersion 动态读取:Platform.operatingSystemVersion 正则提取主版本(如 android-14),不再硬编码
- 队列顺手加固:上传失败批次重回队首而非整批丢弃(一行级缓解,分段持久化属 M2 第二波)

测试新增 9 例:session_tracker_test(5 例:冷启动/短后台/长后台/级联状态/连续幂等),analytics_service_test 补强 4 例(UUIDv7/sessionId 不再逐事件生成/appVersion 可注入/失败重回队列)。

验收对照(06 号报告 §5.1 六条):1✓ SessionTracker 新建、2✓ 三条语义、3✓ 同会话 sessionId 一致、4✓ sessionId 为 UUID 不持久化、5✓ 单测 3 例(实际 5 例)、6✓ 真机脚本(开发者手测)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 14:45:41 +08:00
156 changed files with 1364 additions and 34104 deletions
-4
View File
@@ -26,10 +26,6 @@ jobs:
git remote add origin "$AUTH_URL/${{ github.repository }}.git"
git fetch -q --depth 1 origin "+${{ github.ref }}:refs/ci-head"
git checkout -q refs/ci-head
# 凭证防泄漏兜底(ADR-021):与本地 pre-commit 同一脚本、同一规则表,
# 扫全部已跟踪文件(覆盖本次 push 变更的超集),纯 shell 零外部依赖。
- name: Secret scan
run: sh scripts/check-secrets.sh --all
- name: Install Flutter (flutter-io.cn mirror, toolcache reuse)
run: |
FLUTTER_VERSION=3.44.6
-243
View File
@@ -1,243 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' show ImageByteFormat;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.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/theme/app_theme.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
import 'package:patbond_flutter/features/pets/pet_form_page.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);
}
/// M3.5-01/02/03 compose 真链路桌面实测(默认跳过,不计入常规测试套件):
///
/// ```bash
/// # 先起后端六容器(patbond-api 仓库根 docker compose up -d --build),再:
/// PATBOND_UX_LIVE=1 flutter test integration_test/client_ux_live_test.dart -d linux
/// ```
///
/// 驱动**真实 App**Linux 桌面 GTK 渲染管线 + 真实 HTTP)走用户实测那条路:
/// 注册 → UI 登录 → 档案页建档 → 打开生日选择器(校验全中文 + 品牌配色 +
/// 手输可用 + 一键今天)→ 保存进详情页(校验花费卡展示实际月份 + chevron)。
/// 逐步截图落到 `build/ux-live/`:本机是 Wayland 会话,X11 的
/// `import -window root` 取不到根窗口,改为把整棵 App 包一层
/// [RepaintBoundary] 后 `toImage()` 直出真实渲染像素(含 Overlay 里的弹窗)。
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
final enabled = Platform.environment['PATBOND_UX_LIVE'] == '1';
const authBase = 'http://127.0.0.1:8081';
const petBase = 'http://127.0.0.1:8083';
final shotDir = Directory('build/ux-live');
const rootKey = ValueKey<String>('ux-live-root');
Future<void> shot(WidgetTester tester, String name) async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(rootKey),
);
await tester.runAsync(() async {
if (!shotDir.existsSync()) shotDir.createSync(recursive: true);
final image = await boundary.toImage(pixelRatio: 1.5);
final bytes = await image.toByteData(format: ImageByteFormat.png);
final path = '${shotDir.path}/$name.png';
File(path).writeAsBytesSync(bytes!.buffer.asUint8List());
debugPrint('[shot] $name$path (${bytes.lengthInBytes} bytes)');
});
}
Future<void> pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 25),
}) 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> pumpUntilGone(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 25),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 250));
if (finder.evaluate().isEmpty) return;
}
fail('等待消失超时:$finder');
}
/// 直连服务端下单个 JSON 请求(种子数据走真实 HTTP,不绕 DB)。
Future<Map<String, dynamic>> postJson(
String url,
Map<String, Object?> body, {
String? bearer,
}) async {
final client = HttpClient();
final request = await client.postUrl(Uri.parse(url));
request.headers.contentType = ContentType.json;
if (bearer != null) {
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bearer');
}
request.add(utf8.encode(jsonEncode(body)));
final response = await request.close();
final text = await response.transform(utf8.decoder).join();
client.close();
expect(response.statusCode, anyOf(200, 201), reason: 'POST $url 失败:$text');
final envelope = jsonDecode(text) as Map<String, dynamic>;
expect(envelope['code'], 0, reason: 'POST $url 业务码非 0$text');
return envelope['data'] as Map<String, dynamic>;
}
testWidgets('桌面真链路:日期选择器中文/品牌色/手输/今天 + 花费卡实际月份', (tester) async {
// ---- 注册一次性账号 + 种子数据(宠物 + 当月一笔就医支出)----
final seed = DateTime.now().millisecondsSinceEpoch;
final username = 'uxlive$seed';
final password = 'Live1234!$seed';
await postJson('$authBase/api/v1/auth/register', {
'username': username,
'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}',
'password': password,
});
final tokens = await postJson('$authBase/api/v1/auth/login', {
'username': username,
'password': password,
});
final accessToken = tokens['accessToken'] as String;
final pet = await postJson('$petBase/api/v1/pets', {
'name': '实测豆豆',
'species': 'dog',
'sex': 'male',
'customBreedName': '中华田园犬',
}, bearer: accessToken);
final petId = pet['id'] as String;
// 当月一笔 128.50 元就医支出:花费卡应显示「N 月花费 ¥128.50」——
// 用户那次误录(记到 4 月)看到 ¥0 正是因为记录不在当月窗口内。
await postJson('$petBase/api/v1/pets/$petId/health-events', {
'eventType': 'medical',
'occurredAt': DateTime.now().toUtc().toIso8601String(),
'title': '皮肤检查',
'amountCents': 12850,
}, bearer: accessToken);
// ---- 启动真实 App ----
await tester.pumpWidget(
RepaintBoundary(
key: rootKey,
child: 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('登录'));
// ---- 档案 Tab → 建档表单 ----
await pumpUntil(tester, find.text('档案'));
await tester.tap(find.text('档案'));
// 列表已有种子宠物 → 走虚线「+ 添加宠物」卡(空态 CTA 文案不同)。
await pumpUntil(tester, find.textContaining('添加宠物'));
await tester.tap(find.textContaining('添加宠物').first);
await pumpUntil(tester, find.byType(PetFormPage));
await shot(tester, '01-pet-form');
// ---- 生日日期选择器:全中文 + 品牌配色 ----
await tester.tap(find.widgetWithText(ListTile, '生日(可选)'));
await pumpUntil(tester, find.byType(DatePickerDialog));
await tester.pump(const Duration(milliseconds: 600));
await shot(tester, '02-date-picker-zh');
expect(find.text('选择日期'), findsOneWidget, reason: '标题非中文');
expect(find.text('确定'), findsOneWidget);
expect(find.text('取消'), findsOneWidget);
expect(find.text('Select date'), findsNothing);
expect(find.text('OK'), findsNothing);
final inkColors = tester
.widgetList<Ink>(find.byType(Ink))
.map((ink) => ink.decoration)
.whereType<ShapeDecoration>()
.map((d) => d.color)
.toList();
expect(
inkColors,
contains(AppColors.primaryStrong),
reason: '选中日仍是 fromSeed 派生的暗红棕,未取品牌 primaryStrong',
);
// ---- 手输模式:中文标签 + 敲入已知日期 ----
await tester.tap(find.byIcon(Icons.edit_outlined));
await pumpUntil(tester, find.text('输入日期'));
await shot(tester, '03-date-input-zh');
final field = find.descendant(
of: find.byType(DatePickerDialog),
matching: find.byType(TextField),
);
await tester.enterText(field, '2024/03/15');
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('确定'));
await pumpUntil(tester, find.text('2024-03-15'));
await shot(tester, '04-date-typed');
// ---- 「今天」快捷键:一键落今天,不开弹窗 ----
final todayText =
'${DateTime.now().year}-'
'${DateTime.now().month.toString().padLeft(2, '0')}-'
'${DateTime.now().day.toString().padLeft(2, '0')}';
await tester.tap(find.widgetWithText(TextButton, '今天'));
await pumpUntil(tester, find.text(todayText));
expect(find.byType(DatePickerDialog), findsNothing, reason: '「今天」不该开弹窗');
await shot(tester, '05-today-shortcut');
// ---- 退出建档表单(本单不验建档链路,宠物与支出已由 API 种下)----
await tester.tap(find.byIcon(Icons.arrow_back));
await pumpUntilGone(tester, find.byType(PetFormPage));
// ---- 详情页:花费卡展示实际月份 + chevron 可点提示 ----
await pumpUntil(tester, find.text('实测豆豆'));
await shot(tester, '06-pets-list');
await tester.tap(find.text('实测豆豆'));
await pumpUntil(tester, find.byType(PetDetailPage));
final monthLabel = '${DateTime.now().month} 月花费';
await pumpUntil(tester, find.text(monthLabel));
await tester.pump(const Duration(milliseconds: 600));
await shot(tester, '07-pet-detail-expense-card');
expect(find.text('本月花费'), findsNothing, reason: '仍是硬编码「本月花费」');
// 当月真有支出 → 卡片给出金额(月份口径自证:记录落在当月才计入)。
expect(find.text('¥128.50'), findsOneWidget);
expect(
find.ancestor(
of: find.text(monthLabel),
matching: find.widgetWithIcon(Card, Icons.chevron_right),
),
findsOneWidget,
reason: '花费卡缺 chevron 可点提示',
);
}, skip: !enabled);
}
-112
View File
@@ -1,112 +0,0 @@
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);
}
@@ -1,471 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' show ImageByteFormat;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.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/pet_avatar.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';
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
import 'package:patbond_flutter/features/profile/profile_edit_page.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);
}
/// 固定测试图:16×16 棋盘 PNG(87 字节)。
///
/// **刻意不用 1×1 的极小图**1×1 的 PNG/JPEG 服务端照收、curl 也能原样取回,
/// 但走 `NetworkImage` 的解码路径会抛「Codec failed to produce an image」,
/// UI 于是落到 `RemoteImage` 的兜底占位——本机实测踩过这一格,很容易被误判
/// 成「头像根本没传上去」。用一张最小的**多像素**图即可走通到真实像素。
final Uint8List _pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAHklEQVR42mOo0RV6Fc'
'FDPMlAkmogyTBqw6gNQ8YGAKJxBJBXv4+TAAAAAElFTkSuQmCC',
);
/// 桌面替身:选图直接给上面那张 JPEGLinux 无 image_picker 原生实现)。
class _DesktopPicker implements MediaImagePicker {
int calls = 0;
@override
Future<List<PickedMediaImage>> pickImages({required int limit}) async {
calls += 1;
return [PickedMediaImage(bytes: _pngBytes, name: 'avatar.png')];
}
}
/// 桌面替身:压缩原样透传(Linux 无 flutter_image_compress 原生实现)。
class _PassthroughCompressor implements MediaImageCompressor {
const _PassthroughCompressor();
@override
Future<CompressedMediaImage> compress(
PickedMediaImage source, {
required int quality,
}) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png');
}
/// M3.5-08/09/10 compose 真链路桌面实测(默认跳过,不计入常规测试套件):
///
/// ```bash
/// # 先起后端六容器(patbond-api 仓库根 docker compose up -d --build),再:
/// PATBOND_PROFILE_LIVE=1 \
/// flutter test integration_test/profile_avatar_live_test.dart -d linux
/// ```
///
/// 驱动**真实 App**Linux 桌面 GTK 渲染 + 真实 HTTP + 真实 MinIO)走一遍:
/// UI 注册 → 首页问候语显示用户名 → 资料页显示真实用户名与零值统计 →
/// 设昵称 → 首页问候语与资料页同步变昵称 → Feed 作者名同步(验证服务端
/// `/internal/users/profiles` 的 SQL 回退链)→ 传用户头像 → 宠物详情传头像。
///
/// 只有**选图与压缩两层**是桌面替身;createUpload / 预签名 PUT 直传 /
/// confirm / PATCH 全为生产实现。逐步截图落到 `build/profile-live/`。
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
final enabled = Platform.environment['PATBOND_PROFILE_LIVE'] == '1';
const authBase = 'http://127.0.0.1:8081';
const petBase = 'http://127.0.0.1:8083';
const communityBase = 'http://127.0.0.1:8084';
final shotDir = Directory('build/profile-live');
const rootKey = ValueKey<String>('profile-live-root');
Future<void> shot(WidgetTester tester, String name) async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(rootKey),
);
await tester.runAsync(() async {
if (!shotDir.existsSync()) shotDir.createSync(recursive: true);
final image = await boundary.toImage(pixelRatio: 1.5);
final bytes = await image.toByteData(format: ImageByteFormat.png);
File(
'${shotDir.path}/$name.png',
).writeAsBytesSync(bytes!.buffer.asUint8List());
debugPrint('[shot] $name (${bytes.lengthInBytes} bytes)');
});
}
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> pumpUntilGone(
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().isEmpty) return;
}
fail('等待消失超时:$finder');
}
/// 让出**真实时间**`pump(Duration)` 只推进一帧(把动画时钟往前拨,
/// 并不真的等 5 秒),等网络/等服务端缓存过期必须走 [WidgetTester.runAsync]。
Future<void> settleReal(
WidgetTester tester, {
Duration wait = const Duration(seconds: 2),
}) async {
await tester.runAsync(() => Future<void>.delayed(wait));
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
}
/// 在指定滚动容器里向下滚到目标可见(资料页/列表页的底部入口都需要)。
Future<void> scrollTo(
WidgetTester tester,
Finder list,
Finder target, {
int maxDrags = 10,
}) async {
for (var i = 0; i < maxDrags && target.evaluate().isEmpty; i++) {
await tester.drag(list, const Offset(0, -300));
await tester.pump(const Duration(milliseconds: 300));
}
}
Future<Map<String, dynamic>> postJson(
String url,
Map<String, Object?> body, {
String? bearer,
}) async {
final client = HttpClient();
final request = await client.postUrl(Uri.parse(url));
request.headers.contentType = ContentType.json;
request.headers.set('Idempotency-Key', DateTime.now().toIso8601String());
if (bearer != null) {
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bearer');
}
request.add(utf8.encode(jsonEncode(body)));
final response = await request.close();
final text = await response.transform(utf8.decoder).join();
client.close();
expect(response.statusCode, anyOf(200, 201), reason: 'POST $url$text');
final envelope = jsonDecode(text) as Map<String, dynamic>;
expect(envelope['code'], 0, reason: 'POST $url 业务码非 0$text');
return envelope['data'] as Map<String, dynamic>;
}
String greetingPrefix() {
final hour = DateTime.now().hour;
if (hour < 6) return '夜深了';
if (hour < 11) return '早上好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好';
return '晚上好';
}
testWidgets('桌面真链路:注册 → 资料真实化 → 设昵称 → 传头像 → Feed 作者名 → 宠物头像', (tester) async {
final picker = _DesktopPicker();
final seed = DateTime.now().millisecondsSinceEpoch;
final username = 'plive$seed';
final password = 'Live1234!$seed';
final phone = '+86137${(seed % 100000000).toString().padLeft(8, '0')}';
// 昵称与帖文都带 seed:Feed 是全局的,同名会让断言认错卡片。
final nickname = '实测柴${seed % 1000000}';
final postContent = '资料页实测帖 $seed';
await tester.pumpWidget(
RepaintBoundary(
key: rootKey,
child: App(
sessionManager: SessionManager(store: _InMemoryTokenStore()),
avatarUploaderFactory: (repository, purpose) => MediaUploader(
repository: repository,
purpose: purpose,
maxImages: 1,
maxConcurrentUploads: 1,
picker: picker,
compressor: const _PassthroughCompressor(),
),
),
),
);
// ---- 步骤 1:UI 注册(注册面不收昵称,ADR-022 决策 D3.5-5----
await pumpUntil(tester, find.text('立即注册'));
await tester.tap(find.text('立即注册'));
await pumpUntil(tester, find.text('创建账号'));
// live binding 下首个 enterText 常被丢(焦点还没建立),故每格「点入 →
// 输入 → 验值 → 必要时重输」,本机实测踩过这一格。
for (final entry in [
(0, username),
(1, phone.substring(3)),
(2, password),
(3, password),
]) {
final field = find.byType(TextField).at(entry.$1);
for (var attempt = 0; attempt < 3; attempt++) {
await tester.tap(field);
await tester.pump(const Duration(milliseconds: 200));
await tester.enterText(field, entry.$2);
await tester.pump(const Duration(milliseconds: 300));
if (tester.widget<TextField>(field).controller?.text == entry.$2) break;
}
expect(
tester.widget<TextField>(field).controller?.text,
entry.$2,
reason: '注册表单第 ${entry.$1} 格未收到输入',
);
}
await shot(tester, '01-register');
await tester.tap(find.widgetWithText(FilledButton, '注册'));
// ---- 步骤 2:主壳挂载 → 等 /me 到手(资料页与首页共用这一次请求)----
await pumpUntil(tester, find.text('我的'));
await pumpUntil(tester, find.text('${greetingPrefix()}$username 👋'));
await shot(tester, '02-home-greeting-username');
// 借真实会话种一篇帖与一只宠物(UI 发帖/建档已有各自实测覆盖)。
final tokens = await tester.runAsync(
() => postJson('$authBase/api/v1/auth/login', {
'username': username,
'password': password,
}),
);
final accessToken = tokens!['accessToken'] as String;
await tester.runAsync(
() => postJson('$communityBase/api/v1/posts', {
'category': 'general',
'content': postContent,
'status': 'published',
}, bearer: accessToken),
);
final pet = await tester.runAsync(
() => postJson('$petBase/api/v1/pets', {
'name': '实测豆豆',
'species': 'dog',
'sex': 'male',
'customBreedName': '中华田园犬',
}, bearer: accessToken),
);
// ---- 步骤 2.5:登出 → UI 登录 ----
// 覆盖登录链路,同时让三个控制器重新预取:登出即 reset 是「跨账号不
// 泄漏」的既有纪律(app.dart),重登后各 Tab 从 initial 重新拉取,
// 于是上面用 API 种下的帖与宠物才会进入内存副本。
await tester.tap(find.text('我的'));
await pumpUntil(tester, find.text('@$username'));
final profileList = find
.ancestor(of: find.text('@$username'), matching: find.byType(ListView))
.first;
await scrollTo(tester, profileList, find.text('切换账号或退出登录'));
await pumpUntil(tester, find.text('切换账号或退出登录'));
await tester.tap(find.text('切换账号或退出登录'));
await pumpUntil(tester, find.text('立即注册'));
for (final entry in [(0, username), (1, password)]) {
final field = find.byType(TextField).at(entry.$1);
for (var attempt = 0; attempt < 3; attempt++) {
await tester.tap(field);
await tester.pump(const Duration(milliseconds: 200));
await tester.enterText(field, entry.$2);
await tester.pump(const Duration(milliseconds: 300));
if (tester.widget<TextField>(field).controller?.text == entry.$2) break;
}
}
await tester.tap(find.widgetWithText(FilledButton, '登录'));
await pumpUntil(tester, find.text('我的'));
await pumpUntil(tester, find.text('${greetingPrefix()}$username 👋'));
// ---- 步骤 3:资料页展示真实用户名 + 零值统计(不是 demo 的 24/1.8k/2----
await tester.tap(find.text('我的'));
await pumpUntil(tester, find.text('@$username'));
await shot(tester, '03-profile-real-username');
expect(find.text('萌宠新手(豆豆家长)'), findsNothing);
expect(find.text('1.8k'), findsNothing);
expect(find.text('获赞'), findsOneWidget);
// 统计来自服务端:刚发了 1 帖、没人赞 → 作品 1 / 获赞 0。
expect(
find.descendant(
of: find
.ancestor(of: find.text('我的作品'), matching: find.byType(Column))
.first,
matching: find.text('1'),
),
findsOneWidget,
reason: '作品数应为服务端聚合的 1',
);
// ---- 步骤 4:设昵称 ----
await tester.tap(find.widgetWithText(OutlinedButton, '编辑资料'));
await pumpUntil(tester, find.byType(ProfileEditPage));
expect(
find.text('未设置,当前展示为用户名「$username'),
findsOneWidget,
reason: '编辑页不该把 username 预填进昵称框',
);
await shot(tester, '04-profile-edit-empty');
await tester.enterText(find.byType(TextField).first, nickname);
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.widgetWithText(FilledButton, '保存'));
await pumpUntilGone(tester, find.byType(ProfileEditPage));
await pumpUntil(tester, find.text(nickname));
await shot(tester, '05-profile-nickname-set');
// ---- 步骤 5:上传用户头像(真实两步上传 + PATCH)----
await tester.tap(find.widgetWithText(OutlinedButton, '编辑资料'));
await pumpUntil(tester, find.byType(ProfileEditPage));
await tester.tap(find.widgetWithText(TextButton, '更换头像'));
await pumpUntil(tester, find.text('使用这张'));
await shot(tester, '06-avatar-sheet-ready');
await tester.tap(find.text('使用这张'));
await pumpUntil(tester, find.text('已选择\n新头像'));
await tester.tap(find.widgetWithText(FilledButton, '保存'));
await pumpUntilGone(tester, find.byType(ProfileEditPage));
await pumpUntil(tester, find.text('资料已更新'));
await settleReal(tester);
await shot(tester, '07-profile-avatar-uploaded');
expect(picker.calls, 1);
// ---- 步骤 6:首页问候语 + Feed 作者名同步为昵称 ----
await tester.tap(find.text('首页'));
await pumpUntil(tester, find.text('${greetingPrefix()}$nickname 👋'));
await shot(tester, '08-home-greeting-nickname');
// Feed 首屏是在设昵称之前拉的,故先下拉刷新——作者名要变必须重取,
// 这一步同时验证服务端 `/internal/users/profiles` 的 SQL 回退链
// (客户端对 FeedCard.author.nickname 不做任何拼装)。
final feedList = find.byType(ListView).first;
// ⚠️ 服务端有意的滞后:community 的 AuthorProfileGateway 把
// `/internal/users/profiles` 的结果放在 60s TTL 的进程内缓存里
// `patbond.author-profile.cache-ttl`M3 T3-05)。首屏 Feed 是设昵称
// 之前拉的,那次已把「作者名 = username」写进缓存,所以立刻下拉刷新
// 仍是旧名——这不是客户端 bug,客户端对作者名不做任何拼装。
// 故先等过 TTL,再刷新验证同步。
await settleReal(tester, wait: const Duration(seconds: 65));
var authorSynced = false;
for (var round = 0; round < 3 && !authorSynced; round++) {
// 逐段 moveBy + 抬手才会触发 RefreshIndicatorfling 的速度语义被当成
// 普通滚动,本机实测过);随后必须循环 pump 让真实 HTTP 有时间完成。
final pull = await tester.startGesture(
tester.getTopLeft(feedList) + const Offset(200, 120),
);
for (var i = 0; i < 6; i++) {
await pull.moveBy(const Offset(0, 60));
await tester.pump(const Duration(milliseconds: 80));
}
await pull.up();
await settleReal(tester, wait: const Duration(seconds: 3));
for (var i = 0; i < 8 && find.text(postContent).evaluate().isEmpty; i++) {
await tester.drag(feedList, const Offset(0, -240));
await tester.pump(const Duration(milliseconds: 400));
}
if (find.text(postContent).evaluate().isNotEmpty) {
final card = find.ancestor(
of: find.text(postContent),
matching: find.byType(PostCard),
);
debugPrint(
'[live] 第 $round 轮我的卡片文本:'
'${tester.widgetList<Text>(find.descendant(of: card, matching: find.byType(Text))).map((t) => t.data).join(' | ')}',
);
authorSynced = find
.descendant(of: card, matching: find.text(nickname))
.evaluate()
.isNotEmpty;
}
if (!authorSynced) {
await tester.drag(feedList, const Offset(0, 2000));
await tester.pump(const Duration(milliseconds: 400));
}
}
expect(
authorSynced,
isTrue,
reason: 'Feed 作者名未随昵称变化(服务端 /internal 回退链或刷新未生效)',
);
await shot(tester, '09-feed-author-nickname');
// ---- 步骤 7:宠物详情传头像(WRITE 档,纯头像 PATCH----
await tester.tap(find.text('档案'));
await pumpUntil(tester, find.text('实测豆豆'));
await shot(tester, '10a-pets-list-avatar-placeholder');
await tester.tap(find.text('实测豆豆'));
await pumpUntil(tester, find.byType(PetDetailPage));
await shot(tester, '10-pet-detail-placeholder-avatar');
expect(
tester
.widgetList<PetAvatar>(find.byType(PetAvatar))
.any((a) => a.url == null),
isTrue,
reason: '上传前应是爪印占位',
);
await tester.tap(find.byType(PetAvatar).first);
await pumpUntil(tester, find.text('使用这张'));
await tester.tap(find.text('使用这张'));
await pumpUntil(tester, find.text('头像已更新'));
await settleReal(tester, wait: const Duration(seconds: 6));
await shot(tester, '11-pet-avatar-uploaded');
final detailAvatarUrl = tester
.widgetList<PetAvatar>(find.byType(PetAvatar))
.map((a) => a.url)
.whereType<String>()
.firstOrNull;
debugPrint('[live] 详情头像 URL$detailAvatarUrl');
final imageStatus = await tester.runAsync(() async {
final client = HttpClient();
final response = await (await client.getUrl(
Uri.parse(detailAvatarUrl!),
)).close();
final bytes = await response.fold<int>(0, (n, chunk) => n + chunk.length);
client.close();
return '${response.statusCode} / $bytes bytes';
});
debugPrint('[live] 应用进程内直取头像:$imageStatus');
expect(imageStatus, startsWith('200'), reason: '预签名头像 URL 应可直取');
// 真的画出了像素:详情头像下必须有 Image,且不是 RemoteImage 的兜底
// 占位(占位是 Icon(Icons.pets),画不出图时才出现)。
final detailAvatar = find.byWidgetPredicate(
(widget) => widget is PetAvatar && widget.showEditBadge,
);
expect(
find.descendant(of: detailAvatar, matching: find.byType(Image)),
findsOneWidget,
);
expect(
find.descendant(of: detailAvatar, matching: find.byIcon(Icons.pets)),
findsNothing,
reason: '仍是爪印占位说明图没画出来(后端对了但 UI 没显示)',
);
expect(
tester
.widgetList<PetAvatar>(find.byType(PetAvatar))
.any((a) => (a.url ?? '').contains('pet_avatar')),
isTrue,
reason: '详情页头像应换成 pet_avatar 前缀的预签名 URL',
);
debugPrint('[live] petId=${pet!['id']} username=$username');
}, skip: !enabled);
}
-193
View File
@@ -1,193 +0,0 @@
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 注释);埋点落库
/// 取证走 curl26 号报告 §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);
}
-229
View File
@@ -1,229 +0,0 @@
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;
}
+1 -4
View File
@@ -19,10 +19,7 @@ enum AnalyticsPageName {
create('create'),
petArchive('pet_archive'),
services('services'),
postDetail('post_detail'),
// —— 字典 v3 页面族(22 号报告 §2;本枚举登记 M3 已落地页)——
/// 发布页(P3T3-17 push 全屏页)。
postForm('post_form');
postDetail('post_detail');
const AnalyticsPageName(this.pageName);
+29 -176
View File
@@ -1,54 +1,32 @@
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.
/// Simplified analytics client for M0/M1 (report 13 + ticket 19): track events
/// to backend POST /api/v1/events. In-memory queue flushed every 20 events;
/// failed batches are re-queued (capped at 500, oldest dropped). Persistent
/// segmented queue lands in M2 wave 2. 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;
_osVersion = _defaultOsVersion();
/// 异步设置 appVersionapp.dart 启动时从 package_info_plus 读取后注入)。
void setAppVersion(String version) {
_appVersion = version;
}
// 满 _flushThreshold 条触发一次冲刷(13 号规范 §3.4
// 内存队列:满 _flushThreshold 条上传一次;分段持久化队列排 M2 第二波
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 持久化 key13 号规范 §3.3):首次生成后跨冷启动稳定,
/// 登录前事件才能跨启动归并(A/B 前置 #4 硬依赖)。
static const anonymousIdKey = 'pb.analytics.anonymousId';
// 失败重回队列的容量上限(对齐 13 号规范队列上限),超限丢最旧
static const _maxQueuedEvents = 500;
final String apiBaseUrl;
final String? Function()? getAccessToken;
@@ -56,61 +34,26 @@ class AnalyticsService {
/// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。
final String Function() getSessionId;
/// 前台定时冲刷周期(13 号 §3.4 第 4 触发点),构造参数化便于测试注入。
final Duration flushInterval;
String _anonymousId;
/// 构造显式注入 anonymousId 的测试通道不参与持久化采用/落盘。
final bool _anonymousIdInjected;
final String _anonymousId;
String _appVersion;
final String _osVersion;
String? _userId;
bool _flushing = false;
final AnalyticsEventStore _store;
/// 时钟注入口(照 SessionTracker 先例),退避判定测试免真实等待。
final DateTime Function() _now;
Timer? _flushTimer;
Duration? _backoffDelay;
DateTime? _retryNotBefore;
final List<Map<String, dynamic>> _pendingEvents = [];
/// 待上报事件(测试断言用,生产代码不得直接操作)。
@visibleForTesting
List<Map<String, dynamic>> get pendingEvents => _store.events;
/// 当前匿名标识(测试断言用)。
@visibleForTesting
String get anonymousId => _anonymousId;
List<Map<String, dynamic>> get pendingEvents =>
List.unmodifiable(_pendingEvents);
/// 粗粒度 osVersion13 号规范 §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;
@@ -142,13 +85,13 @@ class AnalyticsService {
'sessionId': getSessionId(),
'clientTs': DateTime.now().toUtc().toIso8601String(),
'appVersion': _appVersion,
'platform': _platformName(),
'platform': Platform.isAndroid ? 'android' : 'ios',
'osVersion': _osVersion,
if (props != null && props.isNotEmpty) 'props': props,
};
await _store.add(event);
if (_store.length >= _flushThreshold) {
_pendingEvents.add(event);
if (_pendingEvents.length >= _flushThreshold) {
await _flush();
}
} catch (error) {
@@ -156,105 +99,30 @@ class AnalyticsService {
}
}
/// 冷启动恢复:先采用持久化 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;
if (_flushing || _pendingEvents.isEmpty) return;
_flushing = true;
final batch = List<Map<String, dynamic>>.from(_pendingEvents);
_pendingEvents.clear();
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);
}
await _upload(batch);
} catch (error) {
// 网络错误 / 5xx:段保留在持久化队列,指数退避后由定时冲刷重试
// 或等显式触发/冷启动重传。
_scheduleBackoff();
debugPrint('Analytics upload failed, events kept queued: $error');
// 顺手加固(03 §1.4 #1 的一行级缓解):失败不再整批丢弃
// 重回队首等下次冲刷;上限 500 条,超限丢最旧。真正的
// shared_preferences 分段持久化队列属 M2 第二波。
debugPrint('Analytics upload failed, requeueing batch: $error');
_pendingEvents.insertAll(0, batch);
if (_pendingEvents.length > _maxQueuedEvents) {
_pendingEvents.removeRange(0, _pendingEvents.length - _maxQueuedEvents);
}
} 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 {
Future<void> _upload(List<Map<String, dynamic>> events) async {
final token = getAccessToken?.call();
final request =
await HttpClient().postUrl(Uri.parse('$apiBaseUrl/api/v1/events'))
@@ -265,24 +133,9 @@ class AnalyticsService {
request.add(utf8.encode(jsonEncode({'events': events})));
final response = await request.close();
if (response.statusCode == 429) {
// 429 按网络错误同路径处理(保段 + 指数退避):后端限流尚未实现
// iteration-2/09 出入项),Retry-After 分支待其落地后一并做。
throw Exception('Upload rate limited (429), events kept queued');
}
if (response.statusCode >= 400 && response.statusCode < 500) {
// 4xx 为永久性拒绝(校验失败/批量超限等),重试不可能成功;
// 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。
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) {
+1 -14
View File
@@ -14,8 +14,6 @@ import 'package:uuid/uuid.dart';
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();
@@ -23,13 +21,6 @@ class SessionTracker with WidgetsBindingObserver {
/// 后台超时阈值;构造参数化便于测试注入。
final Duration timeout;
/// 首次离开前台时回调(app 装配层用于触发埋点队列冲刷)。
final VoidCallback? onLeaveForeground;
/// 回到前台时回调(app 装配层用于恢复 30 秒定时冲刷);
/// 冷启动首个 resumed 不触发(此前未离开过前台)。
final VoidCallback? onEnterForeground;
/// 时钟注入口,测试免真实等待。
final DateTime Function() _now;
@@ -44,17 +35,13 @@ class SessionTracker with WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
final leftAt = _leftForegroundAt;
if (leftAt != null) {
if (_now().difference(leftAt) > timeout) {
if (leftAt != null && _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;
}
+4 -170
View File
@@ -5,53 +5,22 @@ 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/app/app_localization.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/widgets/avatar_upload_sheet.dart';
import 'package:patbond_flutter/features/auth/auth_repository.dart';
import 'package:patbond_flutter/features/auth/login_page.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/auth/splash_page.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import 'package:patbond_flutter/features/main/main_shell_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
import 'package:patbond_flutter/features/pets/pets_controller.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/state/app_state.dart';
class App extends StatefulWidget {
const App({
super.key,
this.sessionManager,
this.authRepository,
this.petsRepository,
this.communityRepository,
this.mediaUploaderFactory,
this.avatarUploaderFactory,
});
const App({super.key, this.sessionManager, this.authRepository});
/// 测试注入口;生产默认走安全存储 + 真实 API。
final SessionManager? sessionManager;
final AuthRepository? authRepository;
final PetsRepository? petsRepository;
final CommunityRepository? communityRepository;
/// 发布页媒体上传器构造口(桌面实测替换选图/压缩层;生产为 null)。
final MediaUploaderFactory? mediaUploaderFactory;
/// 头像上传器构造口(T3.5-08/09)。生产为 null → 走
/// [defaultAvatarUploader];桌面实测与集成测试在此替换选图/压缩层
/// Linux 桌面无 image_picker / 压缩原生实现),网络三段仍是生产实现。
final AvatarUploaderFactory? avatarUploaderFactory;
@override
State<App> createState() => _AppState();
@@ -61,20 +30,10 @@ class _AppState extends State<App> {
late final AppState appState;
late final SessionManager sessionManager;
late final AuthRepository authRepository;
late final PetsController petsController;
late final CommunityController communityController;
late final ProfileController profileController;
late final AvatarUploaderBuilder _avatarUploaderBuilder;
late final PetAnalytics petAnalytics;
late final HealthRecordAnalytics healthRecordAnalytics;
late final FeedAnalytics feedAnalytics;
late final CommunityInteractionAnalytics interactionAnalytics;
late final PostAnalytics postAnalytics;
late final SessionTracker _sessionTracker;
late final AnalyticsService _analytics;
late final PageViewTracker _pageViewTracker;
late final AnalyticsRouteObserver _routeObserver;
TokenRefresher? _sharedRefresher;
@override
void initState() {
@@ -84,26 +43,15 @@ class _AppState extends State<App> {
widget.sessionManager ??
SessionManager(store: const SecureTokenStore());
_sessionTracker = SessionTracker(
onLeaveForeground: () {
// 退后台:停定时冲刷并立即冲刷一次(既有触发点保留)。
_analytics.stopPeriodicFlush();
_analytics.flushNow();
},
onEnterForeground: () => _analytics.startPeriodicFlush(),
);
_sessionTracker = SessionTracker();
WidgetsBinding.instance.addObserver(_sessionTracker);
_analytics = AnalyticsService(
apiBaseUrl: patbondUserApiBaseUrl,
apiBaseUrl: patbondApiBaseUrl,
getAccessToken: () => sessionManager.accessToken,
getSessionId: () => _sessionTracker.sessionId,
);
_initAppVersion();
// 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。
_analytics.restore();
// 前台期间 30 秒定时冲刷(13 号 §3.4 第 4 触发点)。
_analytics.startPeriodicFlush();
_pageViewTracker = PageViewTracker(_analytics.trackEvent);
_routeObserver = AnalyticsRouteObserver(
@@ -112,35 +60,6 @@ class _AppState extends State<App> {
);
authRepository = widget.authRepository ?? _buildRepository();
petsController = PetsController(
repository: widget.petsRepository ?? _buildPetsRepository(),
);
// T3-14Feed segment 接线主壳(数据层 T3-12 就位);T3-16 起
// 点赞/收藏成功埋点经 interactionAnalytics 在 controller 内上报。
interactionAnalytics = CommunityInteractionAnalytics(_analytics.trackEvent);
final communityRepository =
widget.communityRepository ?? _buildCommunityRepository();
communityController = CommunityController(
repository: communityRepository,
interactionAnalytics: interactionAnalytics,
);
// T3.5-08/10:资料与统计单例,资料 Tab 与首页问候语共用(一次 /me
// 供两个消费点,改昵称后两处同时变)。
profileController = ProfileController(
authRepository: authRepository,
communityRepository: communityRepository,
);
// 头像上传:purpose 由调用页给定(user_avatar / pet_avatar)。仓库复用
// community 仓库——media 两步上传端点由 user 服务提供,已在
// _buildCommunityRepository 里单独接线(mediaApi)。
final avatarFactory = widget.avatarUploaderFactory ?? defaultAvatarUploader;
_avatarUploaderBuilder = (purpose) =>
avatarFactory(communityRepository, purpose);
petAnalytics = PetAnalytics(_analytics.trackEvent);
healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent);
feedAnalytics = FeedAnalytics(_analytics.trackEvent);
// T3-17:发布漏斗五事件 + 媒体上传三段(发布页与 MediaUploader 消费)。
postAnalytics = PostAnalytics(_analytics.trackEvent);
// 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件)
sessionManager.addListener(_reportAuthStateChange);
@@ -156,80 +75,22 @@ class _AppState extends State<App> {
}
}
/// 三服务分端口直连(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 refresher = TokenRefresher(dio: dio, session: sessionManager);
final api = ApiClient(
dio: dio,
session: sessionManager,
refresher: refresher,
);
// `/api/v1/me` 由 user 服务(:8082)提供,auth:8081)上没有该路由,
// 故单独接一条 user 线路(与 community 仓库的 mediaApi 同构)。
final userApi = ApiClient(
dio: buildPatbondDio(
session: sessionManager,
baseUrl: patbondUserApiBaseUrl,
),
session: sessionManager,
refresher: refresher,
);
return ApiAuthRepository(
api: api,
userApi: userApi,
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) {
@@ -240,13 +101,6 @@ class _AppState extends State<App> {
}
void _reportAuthStateChange() {
// 登出即清宠物档案、社区 Feed 与本人资料内存副本(跨账号不泄漏;
// 重登后重新拉取)。
if (sessionManager.status == AuthStatus.unauthenticated) {
petsController.reset();
communityController.reset();
profileController.reset();
}
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
final page = switch (sessionManager.status) {
AuthStatus.unauthenticated => AnalyticsPageName.login,
@@ -258,13 +112,9 @@ class _AppState extends State<App> {
@override
void dispose() {
_analytics.stopPeriodicFlush();
sessionManager.removeListener(_reportAuthStateChange);
WidgetsBinding.instance.removeObserver(_sessionTracker);
appState.dispose();
petsController.dispose();
communityController.dispose();
profileController.dispose();
if (widget.sessionManager == null) sessionManager.dispose();
super.dispose();
}
@@ -286,17 +136,6 @@ class _AppState extends State<App> {
return MainShellPage(
key: const ValueKey('shell'),
appState: appState,
petsController: petsController,
communityController: communityController,
profileController: profileController,
currentUserId: sessionManager.userId,
petAnalytics: petAnalytics,
healthRecordAnalytics: healthRecordAnalytics,
feedAnalytics: feedAnalytics,
interactionAnalytics: interactionAnalytics,
postAnalytics: postAnalytics,
mediaUploaderFactory: widget.mediaUploaderFactory,
avatarUploaderBuilder: _avatarUploaderBuilder,
pageViewTracker: _pageViewTracker,
onLogout: authRepository.logout,
);
@@ -309,11 +148,6 @@ class _AppState extends State<App> {
title: 'Patbond',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
// M3.5-01Material 内置组件中文化(此前未配 delegate,日期选择器等
// 全部回退英文兜底)。单语言 zh-CN,不随设备语言回退英文。
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
navigatorObservers: [_routeObserver],
home: ListenableBuilder(
listenable: sessionManager,
-31
View File
@@ -1,31 +0,0 @@
/// 应用本地化配置(M3.5-01)。
///
/// 根因备忘:M3 之前 [MaterialApp] 未配任何 `localizationsDelegates`
/// Flutter 回退到内置的 `DefaultMaterialLocalizations`(仅英文),导致
/// 一切 Material 内置组件(日期选择器标题「Select date」、确定/取消
/// 「OK」/「Cancel」、模式切换 tooltip「Switch to input」、格式报错
/// 「Invalid format.」)全英文,而业务自绘文案全中文——同一弹窗内中英混排。
/// 挂上三件套后分别为「选择日期 / 确定 / 取消 / 切换到输入模式 / 格式无效。」。
///
/// 这里把三件套 delegate 与 locale 收在一处,供 `App` 与 widget 测试共用:
/// 测试若只 `pumpWidget(MaterialApp(home: ...))` 而不带 delegate,看到的仍是
/// 英文兜底,与真机不一致;需要断言中文渲染的测试请一并挂上本文件的常量。
library;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
/// Material / Cupertino / Widgets 三件套本地化 delegate。
const List<LocalizationsDelegate<Object>> appLocalizationsDelegates =
<LocalizationsDelegate<Object>>[
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// 简体中文(中国大陆)。M3.5 仅单语言,暂不做语言切换。
const Locale appLocale = Locale('zh', 'CN');
/// 支持语言列表。仅 zh-CN——避免设备语言为英文时回退到英文,
/// 造成同一界面内业务中文 + 组件英文的混排。
const List<Locale> appSupportedLocales = <Locale>[appLocale];
-27
View File
@@ -1,27 +0,0 @@
/// 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;
}
-65
View File
@@ -1,65 +0,0 @@
import 'package:flutter/foundation.dart';
/// PATCH 请求的**三态字段**:契约 v1.4.0 对「昵称 / 头像」两类天生可选的
/// 字段定型为三态语义(`UpdateMeRequest` 与 `UpdatePetRequest.avatarAssetId`):
///
/// | 态 | JSON 表现 | 服务端语义 |
/// | --- | --- | --- |
/// | [PatchField.absent] | **键不出现** | 不改 |
/// | [PatchField.clear] | 键出现且值为 `null` | 清空 |
/// | [PatchField.value] | 键出现且有值 | 设置 |
///
/// Dart 的 `String?` 只有两态(有值 / null),无法区分「不改」与「清空」——
/// 若把「不改」也编码为 `null`,未改动的字段会被服务端当成清空指令执行
/// (用户只改昵称,头像就被顺手删了)。故三态必须由类型承载,
/// 不能靠 `T?` 加约定。
///
/// 序列化一律经 [writeTo]:它是「absent 不落键」这条纪律的唯一实现处,
/// 各请求 DTO 不自行拼 map,避免某处漏写 `isPresent` 判断。
@immutable
class PatchField<T extends Object> {
/// 不改:键不出现在 JSON 里。
const PatchField.absent() : _present = false, _value = null;
/// 清空:键出现且值为 `null`。
const PatchField.clear() : _present = true, _value = null;
/// 设置为 [value]。
const PatchField.value(T value) : _present = true, _value = value;
final bool _present;
final T? _value;
/// 本次 PATCH 是否触及该字段(决定键是否落进 JSON)。
bool get isPresent => _present;
/// 是否为「显式清空」(present 且值为 null)。
bool get isClear => _present && _value == null;
/// present 且有值时的值;absent 与 clear 均为 null(两者不可由此区分)。
T? get valueOrNull => _value;
/// 按三态把自己写进 [json]absent 不落键;clear 落 `null`
/// 有值时落 [encode] 的产物(缺省原样写入)。
void writeTo(
Map<String, Object?> json,
String key, {
Object? Function(T value)? encode,
}) {
if (!_present) return;
final value = _value;
json[key] = value == null ? null : (encode?.call(value) ?? value);
}
@override
bool operator ==(Object other) =>
other is PatchField<T> &&
other._present == _present &&
other._value == _value;
@override
int get hashCode => Object.hash(_present, _value);
@override
String toString() => _present ? 'PatchField($_value)' : 'PatchField.absent()';
}
-28
View File
@@ -10,29 +10,6 @@ const String patbondApiBaseUrl = String.fromEnvironment(
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] 统一解析成
@@ -98,7 +75,6 @@ class ApiClient {
String path, {
String method = 'POST',
Object? body,
Map<String, Object?>? query,
Map<String, Object?>? headers,
bool requiresAuth = false,
}) async {
@@ -106,7 +82,6 @@ class ApiClient {
path,
method: method,
body: body,
query: query,
headers: headers,
requiresAuth: requiresAuth,
);
@@ -117,7 +92,6 @@ class ApiClient {
path,
method: method,
body: body,
query: query,
headers: headers,
requiresAuth: requiresAuth,
);
@@ -135,14 +109,12 @@ class ApiClient {
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,
+1 -58
View File
@@ -22,62 +22,6 @@ abstract final class ApiCodes {
/// 登录失败次数过多,账号临时锁定(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;
/// 引用了本人所有但非 readyuploading/failed)状态的 assetHTTP 422)。
static const mediaNotReady = 42203;
/// 自关注(仅 PUT;自取关走 DELETE 的 200 幂等 no-op)。
static const selfFollow = 42204;
/// 上传状态不允许确认(非 uploading 态或对象校验未通过,HTTP 422)。
static const mediaUploadStateInvalid = 42205;
}
/// API 调用的类型化异常。页面按类型映射为三层错误呈现
@@ -99,8 +43,7 @@ final class ApiNetworkException extends ApiException {
}
/// 业务错误:错误信封 code != 040000/40100/40900/40901 等)。
/// 领域层可按错误码细分子类型(见 features/pets/pet_exceptions.dart)。
class ApiBusinessException extends ApiException {
final class ApiBusinessException extends ApiException {
const ApiBusinessException({required this.code, required String message})
: super(message);
@@ -1,67 +0,0 @@
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")';
}
-108
View File
@@ -30,10 +30,6 @@ abstract final class AppColors {
/// 主文字。
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);
@@ -54,10 +50,6 @@ abstract final class AppColors {
/// 校验错误文字、错误描边、失败提示(白底上约 5.4:1,AA 达标)。
static const error = Color(0xFFD0342C);
/// error 淡底(8% tint)上的文字(error 本身在自家淡底仅 4.44:1 贴线不过;
/// 与 primary→primaryStrong 同构加深,05 号规范 §2/D8)。
static const errorDark = Color(0xFFB02C25);
/// 品牌渐变:hero 区、头像环、装饰性大面积(不承载正文文字)。
static const brandGradient = LinearGradient(
begin: Alignment.topLeft,
@@ -176,7 +168,6 @@ ThemeData buildAppTheme() {
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
),
),
datePickerTheme: _datePickerTheme,
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.surface,
indicatorColor: AppColors.surfaceTint,
@@ -195,102 +186,3 @@ ThemeData buildAppTheme() {
),
);
}
/// 日期选择器主题(M3.5-01)。
///
/// 根因备忘:此前未定制,`showDatePicker` 完全走 `ColorScheme.fromSeed`
/// 由珊瑚橙 `#FF6F4C` 派生出的 M3 调和色(选中日为暗红棕实底),
/// 与全 app 品牌色脱节。这里只复用 05 号规范(iteration-2/05、iteration-3/05
/// 已审计过的色对,不新造色值:
///
/// | 位置 | 色对 | 对比度 | 来源 |
/// | --- | --- | --- | --- |
/// | 头部(帮助文字 + 标题 + 模式切换图标) | `surfaceTint` 底 + `primaryDark` | 7.98:1 | 选中 chip 同款(iteration-2/05 §3 D7 |
/// | 选中日 / 选中年 | `primaryStrong` 底 + 白字 | 4.49:1 | FAB / 头像徽标同款(同 §2) |
/// | 今日(未选中)描边与文字 | 白底 + `primaryStrong` | 4.49:1 | 同上 |
/// | 未选中日 / 年 | 白底 + `ink` | ≥12:1 | 正文主色 |
/// | 星期表头 | 白底 + `inkSoft` | 6.59:1 | 承载信息的次级文字(§3 DEBT-2) |
/// | 越界不可选日 | 白底 + `muted` | 3.36:1 | 禁用态,DEBT-2 允许的 `muted` 用途 |
/// | 确定 / 取消按钮 | 白底 + `primaryStrong` 文字 | 4.49:1 | 可点击文字链接(§1.1 token 注释) |
final DatePickerThemeData _datePickerTheme = DatePickerThemeData(
backgroundColor: AppColors.surface,
elevation: 0,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)),
side: BorderSide(color: AppColors.border),
),
dividerColor: AppColors.border,
headerBackgroundColor: AppColors.surfaceTint,
headerForegroundColor: AppColors.primaryDark,
headerHelpStyle: const TextStyle(
color: AppColors.primaryDark,
fontSize: 12,
fontWeight: FontWeight.w600,
),
headerHeadlineStyle: const TextStyle(
color: AppColors.primaryDark,
// 22(默认 32 偏大):中文 `formatMediumDate` 是「9月10日周四」5~6 字,
// 横屏侧栏头部宽度下 26 起就会折行,22 一行放得下。
fontSize: 22,
fontWeight: FontWeight.w800,
),
// 年份下拉 / 上下月箭头一行:与头部同族深色,白底 8.74:1。
subHeaderForegroundColor: AppColors.primaryDark,
weekdayStyle: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
fontWeight: FontWeight.w600,
),
dayStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
dayForegroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) return AppColors.muted;
if (states.contains(WidgetState.selected)) return Colors.white;
return AppColors.ink;
}),
dayBackgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return AppColors.primaryStrong;
return null;
}),
dayOverlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return Colors.white24;
return AppColors.primary.withAlpha(31);
}),
todayForegroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) return AppColors.muted;
if (states.contains(WidgetState.selected)) return Colors.white;
return AppColors.primaryStrong;
}),
todayBackgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return AppColors.primaryStrong;
return null;
}),
// 今日始终带描边(选中态由实底承载,未选中态靠 1.5px 描边定位):
// `primaryStrong` 白底 4.49:1,超过非文字元素 3:1 门槛。
todayBorder: const BorderSide(color: AppColors.primaryStrong, width: 1.5),
yearStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
yearForegroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) return AppColors.muted;
if (states.contains(WidgetState.selected)) return Colors.white;
return AppColors.ink;
}),
yearBackgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return AppColors.primaryStrong;
return null;
}),
yearOverlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return Colors.white24;
return AppColors.primary.withAlpha(31);
}),
cancelButtonStyle: TextButton.styleFrom(
foregroundColor: AppColors.inkSoft,
minimumSize: const Size(64, 44),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
confirmButtonStyle: TextButton.styleFrom(
foregroundColor: AppColors.primaryStrong,
minimumSize: const Size(64, 44),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
);
-144
View File
@@ -1,144 +0,0 @@
/// 日期录入共享层(M3.5-02)。
///
/// 根因备忘:全仓 7 处 `showDatePicker` 各写一遍裸调用,用户实测暴露两个问题:
///
/// 1. **月份只能靠 `< >` 逐月切**。Flutter 原生日历有年份网格、没有月份网格,
/// 从 9 月回到 4 月要点 5 次箭头。已实际导致误录——用户把当月(2026-09)
/// 的就医记录记成了 2026-04-09,进而误判「本月花费 ¥0」是统计坏了。
/// 2. **键盘输入模式在英文兜底下不可用**。原生 calendar 模式本就带一枚
/// 切换到手输的铅笔按钮,但未配本地化时按钮 tooltip 是
/// `Switch to input`、报错是 `Invalid format.`,中文用户看不懂也不敢用。
/// M3.5-01 挂上 zh-CN delegate 后变成「切换到输入模式」/「格式无效。」/
/// 「超出范围。」,输入框提示也从 `mm/dd/yyyy` 变成中文习惯的
/// `yyyy/mm/dd`,这条路才真正走通。
///
/// 本文件的处置:
///
/// - [pickAppDate] 收口 7 处调用:统一 calendar 首屏 + **保留**手输切换
/// (明确不用 `calendarOnly`——那恰好会砍掉手输这条快路),并把
/// `initialDate` 夹进 `[firstDate, lastDate]` 防越界断言。各调用点原有的
/// 业务约束(如健康事件 `lastDate: now` 不许未来)由调用方原样传入,不改。
/// - [AppDateFieldTrailing] 给 7 处日期行统一挂「今天」快捷键:绝大多数录入
/// 就是「记今天的事」,一键落值比开弹窗再找今天更快,也把「月份导航」
/// 这条容易走错的路整段绕开。原生 `showDatePicker` 无法注入自定义动作
/// `builder` 只能包裹整个 Dialog,拿不到它的内部选中态),所以快捷键
/// 放在调用方表单行而非弹窗内。
library;
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// 抹掉时分秒,只留年月日(日期选择器返回值与业务日期字段的统一口径)。
DateTime dateOnly(DateTime value) =>
DateTime(value.year, value.month, value.day);
/// 今天(本地时区,仅日期)。
DateTime today() => dateOnly(DateTime.now());
/// 判断 [date] 是否落在 `[firstDate, lastDate]` 闭区间内(按日粒度)。
bool isDateSelectable({
required DateTime date,
required DateTime firstDate,
required DateTime lastDate,
}) {
final day = dateOnly(date);
return !day.isBefore(dateOnly(firstDate)) && !day.isAfter(dateOnly(lastDate));
}
/// 全仓统一的日期选择入口。
///
/// [initialDate] 会被夹进 `[firstDate, lastDate]`——调用方常传「当前值 ?? 今天」,
/// 而部分表单的 `firstDate` 就是今天(如提醒到期日),历史值可能已越界。
///
/// 返回 `null` 表示用户取消;返回值已抹掉时分秒。
Future<DateTime?> pickAppDate({
required BuildContext context,
required DateTime initialDate,
required DateTime firstDate,
required DateTime lastDate,
}) async {
final first = dateOnly(firstDate);
final last = dateOnly(lastDate);
var initial = dateOnly(initialDate);
if (initial.isBefore(first)) initial = first;
if (initial.isAfter(last)) initial = last;
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: first,
lastDate: last,
// calendar 首屏 + 头部铅笔按钮切手输:日期不确定时翻日历,日期已知时
// 一行敲完。不用 calendarOnly(砍掉手输)、不用 input 首屏(多数录入
// 是「今天/最近几天」,日历一眼可点,手输反而更慢)。
initialEntryMode: DatePickerEntryMode.calendar,
// 帮助文字/确定/取消/手输提示与格式报错全部交给 zh-CN 本地化
// appLocalizationsDelegates),不在此硬编码中文,避免两处文案漂移。
);
return picked == null ? null : dateOnly(picked);
}
/// 日期行尾部(7 处日期 [ListTile] 统一形态):「今天」快捷键 + 日历图标。
///
/// - 「今天」直接把字段落到今天,不开弹窗;今天越界(业务不允许)时按钮
/// 自动隐藏,只留日历图标。
/// - 触控目标 44×44(项目最小触控口径),文字 `primaryStrong` 白底 4.49:1。
class AppDateFieldTrailing extends StatelessWidget {
const AppDateFieldTrailing({
required this.firstDate,
required this.lastDate,
required this.onToday,
super.key,
this.enabled = true,
});
/// 业务允许的最早日期(与 [pickAppDate] 同一约束)。
final DateTime firstDate;
/// 业务允许的最晚日期。
final DateTime lastDate;
/// 点「今天」的回调,入参为今天(仅日期)。
final ValueChanged<DateTime> onToday;
/// 表单提交中等禁用态。
final bool enabled;
@override
Widget build(BuildContext context) {
final now = today();
final showToday = isDateSelectable(
date: now,
firstDate: firstDate,
lastDate: lastDate,
);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showToday)
Tooltip(
message: '设为今天',
child: TextButton(
onPressed: enabled ? () => onToday(now) : null,
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryStrong,
disabledForegroundColor: AppColors.muted,
minimumSize: const Size(44, 44),
padding: const EdgeInsets.symmetric(horizontal: 8),
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
child: const Text('今天'),
),
),
const Icon(
Icons.calendar_month_outlined,
size: 20,
color: AppColors.muted,
),
],
);
}
}
-6
View File
@@ -11,7 +11,6 @@ class AppTextField extends StatefulWidget {
super.key,
this.controller,
this.prefixIcon,
this.prefixText,
this.errorText,
this.helperText,
this.enabled = true,
@@ -26,10 +25,6 @@ class AppTextField extends StatefulWidget {
final String label;
final TextEditingController? controller;
final IconData? prefixIcon;
/// 固定前缀文本(如手机号的 '+86 '),仅展示与拼接语义,不进输入值。
final String? prefixText;
final String? errorText;
final String? helperText;
final bool enabled;
@@ -67,7 +62,6 @@ class _AppTextFieldState extends State<AppTextField> {
labelText: widget.label,
errorText: widget.errorText,
helperText: widget.helperText,
prefixText: widget.prefixText,
prefixIcon: widget.prefixIcon == null
? null
: Icon(widget.prefixIcon, color: AppColors.muted),
-271
View File
@@ -1,271 +0,0 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.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';
/// 页面侧的构造口:调用页只知道「用途」,仓库与选图/压缩层的装配由
/// `app.dart` 在装配处完成。
///
/// 缺省为 null 时页面**不渲染头像上传入口**:意为「本次构建未装配上传能力」,
/// 而不是「有入口但点了没反应」。生产装配恒注入(见 `app.dart`)。
typedef AvatarUploaderBuilder = MediaUploader Function(MediaPurpose purpose);
/// App 级注入口(沿 `MediaUploaderFactory` 先例:工厂拿到已装配的仓库)。
///
/// 生产缺省 [defaultAvatarUploader]**桌面实测与集成测试**在此替换选图与
/// 压缩两层——Linux 桌面既无 image_picker 也无 flutter_image_compress 的
/// 原生实现,而 createUpload / 预签名 PUT 直传 / confirm 三段仍走生产实现。
typedef AvatarUploaderFactory =
MediaUploader Function(
CommunityRepository repository,
MediaPurpose purpose,
);
/// 生产缺省头像上传器:单图、单并发(头像没有批量语义)。
MediaUploader defaultAvatarUploader(
CommunityRepository repository,
MediaPurpose purpose,
) => MediaUploader(
repository: repository,
purpose: purpose,
maxImages: 1,
maxConcurrentUploads: 1,
);
/// 弹出头像上传流程,返回**ready 的 assetId**;用户取消或未走到 ready
/// 即 null(孤儿防护:未 confirm 的 asset 绝不外露,见 [MediaUploader])。
Future<String?> showAvatarUploadSheet(
BuildContext context, {
required AvatarUploaderBuilder builder,
required MediaPurpose purpose,
String title = '更换头像',
}) {
return showModalBottomSheet<String>(
context: context,
useSafeArea: true,
isScrollControlled: true,
showDragHandle: true,
builder: (context) =>
AvatarUploadSheet(uploader: builder(purpose), title: title),
);
}
/// 头像上传 sheet:复用发布页的 [MediaUploader] 六态编排(queued /
/// compressing / uploading / confirming / ready / failed),单图上限。
///
/// 与九宫格的差异只在呈现:这里一次只有一张图,故用整幅预览 + 线性进度
/// 条,而不是格内 [UploadProgressOverlay];状态机、凭据过期换新、失败可
/// 重试、孤儿防护全部沿用编排器,本组件不复制任何上传逻辑。
///
/// **ready 后仍需用户点「使用这张」**:上传成功不等于用户满意这张图,
/// 自动关闭会剥夺预览确认的机会(头像是长期可见的身份标识)。
class AvatarUploadSheet extends StatefulWidget {
const AvatarUploadSheet({
required this.uploader,
required this.title,
super.key,
});
/// 本次会话专属的上传器(purpose 与 maxImages=1 由构造口设定);
/// 关闭 sheet 即 dispose,在途请求经 [MediaUploader.reset] 作废。
final MediaUploader uploader;
final String title;
@override
State<AvatarUploadSheet> createState() => _AvatarUploadSheetState();
}
class _AvatarUploadSheetState extends State<AvatarUploadSheet> {
MediaUploader get _uploader => widget.uploader;
@override
void initState() {
super.initState();
_uploader.addListener(_onChanged);
// 打开即拉起选择器:sheet 的唯一目的就是选一张图,多一次「选择图片」
// 点击是纯摩擦。用户在系统选择器里取消后回落到空态(可再次选择)。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _uploader.pickAndAdd();
});
}
void _onChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_uploader.removeListener(_onChanged);
// 在途任务按 cancelled 收敛(未 confirm 的服务端 asset 弃引用,
// 由服务端超时清理兜底)。
_uploader
..reset()
..dispose();
super.dispose();
}
Future<void> _reselect() async {
// 换图前先清空:maxImages=1 时不清空则 remainingSlots=0,选择器不会拉起。
_uploader.reset();
await _uploader.pickAndAdd();
}
@override
Widget build(BuildContext context) {
final items = _uploader.items;
final item = items.isEmpty ? null : items.first;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
if (item != null) _preview(item),
if (item != null) const SizedBox(height: 14),
..._body(item),
],
),
);
}
/// 预览:原图字节直接解码(不落盘),圆形裁切以贴合头像最终形态。
Widget _preview(MediaUploadItem item) {
return Center(
child: ClipOval(
child: Image.memory(
item.previewBytes,
width: 132,
height: 132,
fit: BoxFit.cover,
errorBuilder: (context, error, stack) => Container(
width: 132,
height: 132,
color: AppColors.surfaceTint,
child: const Icon(Icons.image_outlined, color: AppColors.muted),
),
),
),
);
}
List<Widget> _body(MediaUploadItem? item) {
if (item == null) {
// 选择器拉起中 / 用户取消后的空态。
return _uploader.isPicking
? const [_BusyLine(label: '正在打开相册…')]
: [
const Text(
'还没有选择图片',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _reselect,
icon: const Icon(Icons.photo_library_outlined, size: 18),
label: const Text('选择图片'),
),
];
}
switch (item.phase) {
case MediaItemPhase.queued:
case MediaItemPhase.compressing:
return const [_BusyLine(label: '正在处理图片…')];
case MediaItemPhase.uploading:
return [
_ProgressLine(progress: item.progress),
const SizedBox(height: 10),
Text(
'上传中 ${(item.progress * 100).round()}%',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
];
case MediaItemPhase.confirming:
return const [
_ProgressLine(progress: 1),
SizedBox(height: 10),
Text(
'正在确认…',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
];
case MediaItemPhase.ready:
return [
FilledButton(
onPressed: () => Navigator.of(context).pop(item.assetId),
child: const Text('使用这张'),
),
const SizedBox(height: 8),
TextButton(onPressed: _reselect, child: const Text('重新选择')),
];
case MediaItemPhase.failed:
return [
Text(
item.errorMessage ?? '上传失败',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.errorDark, fontSize: 13),
),
const SizedBox(height: 12),
// 不可重试(如压缩后仍超 10 MB)只给「重新选择」——重试同一张
// 必然再失败,给重试钮是误导。
if (item.retryable)
FilledButton(
onPressed: () => _uploader.retry(item.localId),
child: const Text('重试'),
),
if (item.retryable) const SizedBox(height: 8),
TextButton(onPressed: _reselect, child: const Text('重新选择')),
];
}
}
}
class _BusyLine extends StatelessWidget {
const _BusyLine({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 10),
Text(
label,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
],
);
}
}
class _ProgressLine extends StatelessWidget {
const _ProgressLine({required this.progress});
final double progress;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(AppRadius.pill)),
child: LinearProgressIndicator(
value: progress,
minHeight: 6,
backgroundColor: AppColors.surfaceTint,
color: AppColors.primary,
),
);
}
}
-141
View File
@@ -1,141 +0,0 @@
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,
),
),
),
),
),
],
),
],
),
),
),
],
);
}
}
@@ -1,78 +0,0 @@
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!),
),
],
],
),
);
}
}
-125
View File
@@ -1,125 +0,0 @@
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),
),
);
}
}
-203
View File
@@ -1,203 +0,0 @@
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,
),
),
],
),
),
),
),
);
}
}
-172
View File
@@ -1,172 +0,0 @@
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 裁决);T3.5-09 起 [url] 由服务端
/// `Pet.avatarUrl`(预签名 GET)驱动,[url] 为 null 时仍渲染本地占位形态
/// `surfaceTint` 底 + `Icons.pets`)——签一个必然 404 的 URL 比返回 null
/// 更糟,故服务端对非 ready 的 asset 直接给 null,客户端只有一种占位逻辑。
/// 有图时经 [RemoteImage] 加载(缓存 key 已剥签名参数;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 为本地占位形态(无头像 / asset 非 ready / 存储未配置)。
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 1505 §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),
);
}
}
-240
View File
@@ -1,240 +0,0 @@
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,
),
),
],
),
),
),
);
}
}
-367
View File
@@ -1,367 +0,0 @@
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:160% 档
/// 3.88:1 不达标弃用,§5.1 精算)。
/// - 单图折叠形态:Feed 卡片契约只带封面 + mediaCountFeedCard 裁剪),
/// [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;
}
}
-144
View File
@@ -1,144 +0,0 @@
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.167.39:1(非文字 ≥3:1)、
/// 文字于 dot 底 5.788.74:1AA ≥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),
);
}
}
@@ -1,143 +0,0 @@
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..1uploading 态显示;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);
}
}
+27
View File
@@ -82,6 +82,33 @@ const initialPet = PetProfile(
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 = [
PostModel(
id: 'post1',
+1 -68
View File
@@ -1,10 +1,4 @@
/// 认证接口的响应模型(接口契约冻结稿,字段名与后端一致)。
library;
import 'package:patbond_flutter/core/models/patch_field.dart';
export 'package:patbond_flutter/core/models/patch_field.dart';
class AuthTokens {
const AuthTokens({
required this.userId,
@@ -38,16 +32,11 @@ class AuthTokens {
final DateTime refreshTokenExpiresAt;
}
/// `GET` / `PATCH /api/v1/me` 的用户资料(契约 v1.4.0 恰好这 6 个字段)
///
/// **不含 `avatarAssetId`**:客户端对头像 asset 只写不读,「有头像」等价于
/// [avatarUrl] 非 null。
/// `GET /api/v1/me` 的用户资料。
class UserProfile {
const UserProfile({
required this.userId,
required this.username,
required this.nickname,
required this.avatarUrl,
required this.phone,
required this.createdAt,
});
@@ -56,11 +45,6 @@ class UserProfile {
return UserProfile(
userId: json['userId'] as String,
username: json['username'] as String,
// DB 原值:服务端**刻意不做 username 回退**v1.4.0 定型,理由见
// [displayName]),未设置即 null。
nickname: json['nickname'] as String?,
// 时效性预签名 GET URL,每次响应现签;不得持久化、过期即重取。
avatarUrl: json['avatarUrl'] as String?,
// 服务端可返回 null(历史数据或未来第三方注册),不得非空强转。
phone: json['phone'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
@@ -69,57 +53,6 @@ class UserProfile {
final String userId;
final String username;
/// 昵称 DB 原值,未设置为 null。**编辑态预填只能用它**,不能用
/// [displayName]——否则保存时会把展示回退值固化成真实昵称。
final String? nickname;
/// 头像预签名 GET URL(会过期,禁止入本地存储);无头像 / asset 非
/// ready / 对象存储未配置三种情况均为 null。
final String? avatarUrl;
final String? phone;
final DateTime createdAt;
/// 本人视角的展示名:`nickname ?? username`。
///
/// **回退刻意做在客户端展示层**:服务端 `/me` 返回 DB 原值不回退(契约
/// v1.4.0 §Me),因为 `/me` 是本人编辑态——若服务端回退,编辑页会把
/// `llx` 预填进昵称框,用户误以为设过昵称,下次保存即把这个展示约定
/// **固化成真实数据**`/internal/users/profiles` 的 SQL 回退链从此再不
/// 触发。他人视角(Feed 作者名)的回退由服务端 SQL 层承担,客户端不重复。
String get displayName => nickname ?? username;
/// 是否已有头像(响应不回显 avatarAssetId,判定只看 URL 是否现签成功)。
bool get hasAvatar => avatarUrl != null;
}
/// `PATCH /api/v1/me` 请求体(契约 v1.4.0**三态语义**
/// 键缺省 = 不改 / 显式 `null` = 清空 / 给值 = 设置)。
///
/// 两字段都 absent 即**空 patch**——服务端答 400/40000 而非静默 200
/// 故调用前应以 [isEmpty] 短路(无变更不发请求)。
///
/// 昵称的服务端校验:btrim 后长度按**码点**计 1~32;纯空白或空串是
/// 400/40000 而**不是**隐式清空(清空只走 [PatchField.clear] 一条路)。
class UpdateMeRequest {
const UpdateMeRequest({
this.nickname = const PatchField<String>.absent(),
this.avatarAssetId = const PatchField<String>.absent(),
});
final PatchField<String> nickname;
/// 头像 asset(两步上传产物,`purpose` 须为 `user_avatar`)。
final PatchField<String> avatarAssetId;
/// 本次 patch 未触及任何字段(发出去必得 400/40000)。
bool get isEmpty => !nickname.isPresent && !avatarAssetId.isPresent;
Map<String, Object?> toJson() {
final json = <String, Object?>{};
nickname.writeTo(json, 'nickname');
avatarAssetId.writeTo(json, 'avatarAssetId');
return json;
}
}
+3 -32
View File
@@ -28,33 +28,18 @@ abstract class AuthRepository {
Future<SessionRestoreResult> restoreSession();
Future<UserProfile> me();
/// 本人资料部分更新(昵称 / 头像,三态语义见 [UpdateMeRequest])。
/// 成功返回**更新后的全量 [UserProfile]**(服务端回显,含现签 avatarUrl)。
Future<UserProfile> updateMe(UpdateMeRequest request);
}
/// 基于 [ApiClient] 的实现。
///
/// **端口线路**(ADR-002 无网关,分端口直连):注册/登录/登出/刷新走 auth
/// 服务(:8081);`GET` 与 `PATCH /api/v1/me` 由 **user 服务**:8082
/// `MeController`)提供,故经 [userApi] 直连——auth 上没有 `/api/v1/me`
/// 路由,走主客户端会得到 404。未提供 [userApi] 时回落主客户端(既有测试
/// 桩场景)。同一线路分离手法与 `ApiCommunityRepository` 的 `mediaApi` 一致。
class ApiAuthRepository implements AuthRepository {
ApiAuthRepository({
required ApiClient api,
required this._api,
required this._session,
required this._refresher,
ApiClient? userApi,
this._analytics,
this._uuid = const Uuid(),
}) : _api = api,
// 缺省回落主客户端:既有测试桩只注入一个 ApiClient。
_userApi = userApi ?? api;
});
final ApiClient _api;
final ApiClient _userApi;
final SessionManager _session;
final TokenRefresher _refresher;
final AnalyticsService? _analytics;
@@ -147,25 +132,11 @@ class ApiAuthRepository implements AuthRepository {
@override
Future<UserProfile> me() async {
final data = await _userApi.request(
final data = await _api.request(
'/api/v1/me',
method: 'GET',
requiresAuth: true,
);
return UserProfile.fromJson(data! as Map<String, dynamic>);
}
@override
Future<UserProfile> updateMe(UpdateMeRequest request) async {
// 无乐观锁、无幂等键(契约定型:唯一合法写者 + 列级选择性 UPDATE,
// 同 body 重放天然幂等)。三态由 request.toJson 表达——「不改」的字段
// 根本不出现在 body 里,绝不发成 null。
final data = await _userApi.request(
'/api/v1/me',
method: 'PATCH',
body: request.toJson(),
requiresAuth: true,
);
return UserProfile.fromJson(data! as Map<String, dynamic>);
}
}
+1 -2
View File
@@ -147,7 +147,7 @@ class _RegisterPageState extends State<RegisterPage> {
try {
await widget.authRepository.register(
username: _usernameCtrl.text.trim(),
phone: '+86${_phoneCtrl.text.trim()}',
phone: _phoneCtrl.text.trim(),
password: _passwordCtrl.text,
);
TextInput.finishAutofillContext();
@@ -232,7 +232,6 @@ class _RegisterPageState extends State<RegisterPage> {
label: '手机号',
controller: _phoneCtrl,
prefixIcon: Icons.phone_iphone_rounded,
prefixText: '+86 ',
errorText: _phoneError,
enabled: !_submitting,
keyboardType: TextInputType.phone,
@@ -1,336 +0,0 @@
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();
}
}
@@ -1,59 +0,0 @@
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 _ => '请求过于频繁,请稍后再试',
_ => '草稿保存失败,请重试',
};
@@ -1,102 +0,0 @@
import 'package:patbond_flutter/core/network/api_exception.dart';
/// community / media 域类型化业务异常(契约 v1.3.0 定型的 9 个新错误码,
/// 20 号收口报告 §140902 乐观锁与 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);
}
/// 40405media 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:引用了本人所有但非 readyuploading/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,
};
}
@@ -1,137 +0,0 @@
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_errorserver_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(51500) / 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});
}
}
@@ -1,657 +0,0 @@
/// 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 恒 publicfollowers/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');
}
/// 上传用途白名单(契约 v1.4.0 三值;决定 objectKey 前缀)。
///
/// **用途即引用侧的类型检查**:引用时服务端校验 purpose 相符,故帖图不能
/// 当头像、两种头像也互不通用(不符者 404/40405)。
enum MediaPurpose {
postImage('post_image'),
userAvatar('user_avatar'),
petAvatar('pet_avatar');
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 至多一个 truecaption 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~2000replyToUserId 可选 @ 回复目标
/// (单层平铺,无楼中楼)。
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|webpbyteSize ≤ 1048576010 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 分钟,过期后
/// 重新创建上传;直传必须原样携带 requiredHeadersContent-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 assetconfirm 后的可引用形态)。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;
}
/// 我的社区数字(`GET /api/v1/me/community-stats`,契约 v1.4.0 新增)。
///
/// 两数同一集合:本人的、已发布的、未软删的帖(草稿 / 软删 / hidden /
/// archived 均不计;自己赞自己**计入**,与帖子详情的 likeCount 同口径)。
/// 空数据为 `0` 而非 null,且该端点**永不 404**——任何已认证用户都有 stats。
class CommunityStats {
const CommunityStats({
required this.receivedLikeCount,
required this.publishedPostCount,
});
factory CommunityStats.fromJson(Map<String, dynamic> json) {
return CommunityStats(
receivedLikeCount: json['receivedLikeCount'] as int,
publishedPostCount: json['publishedPostCount'] as int,
);
}
final int receivedLikeCount;
final int publishedPostCount;
}
@@ -1,315 +0,0 @@
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,
});
// ---- Feedcursor 分页)----
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);
// ---- 我的社区数字(主体恒为调用者,路径上无 userId)----
Future<CommunityStats> getMyCommunityStats();
}
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
/// 拦截 + 401/40101 单飞刷新重放);community 域业务错误码升格为类型化异常。
///
/// 幂等:createPost / createComment 两个 POST 按契约**必带**
/// `Idempotency-Key`1~128 字符;每次逻辑提交换新键;token 刷新后的
/// 自动重放沿用同一个键——键在本层每次调用生成一次,重放走同一 headers)。
/// 点赞/收藏/关注为 PUT/DELETE 语义幂等,无需幂等键。
///
/// 端口线路:community 域端点走 community 服务客户端;media 两步上传
/// 端点由 **user 服务**提供(13 号报告 §2MediaController 在
/// 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));
}
// ---- 我的社区数字 ----
@override
Future<CommunityStats> getMyCommunityStats() async {
final data = await _request('/api/v1/me/community-stats');
return CommunityStats.fromJson(_asMap(data));
}
}
-114
View File
@@ -1,114 +0,0 @@
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 仅接 hometopic / 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_errorserver_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,
);
}
}
-110
View File
@@ -1,110 +0,0 @@
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,
);
}
}
@@ -1,53 +0,0 @@
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');
}
}
@@ -1,82 +0,0 @@
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',
);
}
}
}
-48
View File
@@ -1,48 +0,0 @@
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;
}
}
-577
View File
@@ -1,577 +0,0 @@
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..1uploading 有意义;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.purpose = MediaPurpose.postImage,
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;
/// 上传用途(决定服务端 objectKey 前缀,也是引用侧的类型检查依据):
/// 发布页 `post_image`、资料页头像 `user_avatar`、宠物头像 `pet_avatar`。
/// 用途不符的 asset 在引用时被答 404/40405,所以这个值必须由调用方
/// 按场景显式给对(M3.5-08/09 起本类不再只服务发布页)。
final MediaPurpose purpose;
/// 九宫格上限(05 号规范 §3.2);头像场景传 1。
final int maxImages;
final int maxConcurrentUploads;
/// 与服务端 byteSize 上限一致(10 MiB13 号报告偏差 #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;
// confirm42205 对象未上传保持 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: purpose,
mimeType: compressed.mimeType,
byteSize: compressed.byteSize,
),
);
}
bool _credentialsExpired(MediaUploadCredentials credentials) =>
!_now().add(credentialsSafetyMargin).isBefore(credentials.expiresAt);
void _failFromApi(_UploadTask task, Exception error) {
// 参数被服务端拒绝(40000mime/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,
);
-239
View File
@@ -1,239 +0,0 @@
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 / feedtopic_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,
});
}
}
@@ -1,704 +0,0 @@
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';
/// 发布页(P305 号规范 §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;
/// 发布 gating05 §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,
),
),
],
);
}
}
-142
View File
@@ -1,142 +0,0 @@
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;
}
+30 -46
View File
@@ -7,23 +7,15 @@ import 'package:patbond_flutter/widgets/common.dart';
enum CreationMode { image, video }
/// 创作 Tab。
///
/// **AI 生成模拟(700/650/500ms 假延时、风格/模型/分辨率设置、结果卡)
/// 属 M4 范围,T3-17 原样保留**;社区发布半边自 T3-17 起改由真实发布页
/// `PostComposePage`push 全屏)承担——本页顶部「发布动态」入口即其
/// 入口(entryPoint=create_tab),`AppState.publishPost` demo 发布流退役。
class CreatePage extends StatefulWidget {
const CreatePage({
required this.appState,
required this.onOpenCompose,
required this.onPublished,
super.key,
});
final AppState appState;
/// 真实发布页入口(主壳 push,发布成功后回首页 Feed 刷新)。
final VoidCallback onOpenCompose;
final ValueChanged<PostModel> onPublished;
@override
State<CreatePage> createState() => _CreatePageState();
@@ -119,13 +111,36 @@ class _CreatePageState extends State<CreatePage> {
setState(() => tags = [...tags, value]);
}
/// AI 作品的社区发布留待 M4:AI 结果是生成图(无本地文件、无 media
/// asset),走不了两步上传,故不接真实发布链路;demo 发布流(写
/// `AppState.posts`)随 T3-17 退役,此处只余占位提示。
void publish() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('AI 作品发布随 AI 创作能力上线(M4);发布普通动态请用上方「发布动态」')),
if (resultUrl == null || titleController.text.trim().isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请先完成生成并填写标题')));
return;
}
final post = PostModel(
id: 'post_user_${DateTime.now().millisecondsSinceEpoch}',
authorName: '萌宠新手(我)',
authorAvatar: userAvatar,
time: '刚刚',
breedTag: 'AI创作',
content:
'${titleController.text.trim()}\n\n${contentController.text.trim()}',
mainImage: resultUrl!,
likes: 1,
tags: [...tags, mode == CreationMode.image ? 'AI生图' : 'AI视频'],
comments: const [],
hasLiked: true,
);
widget.appState.publishPost(post);
setState(() {
uploaded = false;
resultUrl = null;
generationStep = 0;
titleController.clear();
contentController.clear();
});
widget.onPublished(post);
}
@override
@@ -133,8 +148,6 @@ class _CreatePageState extends State<CreatePage> {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
_ComposeEntryCard(onTap: widget.onOpenCompose),
const SizedBox(height: 18),
SegmentedButton<CreationMode>(
segments: const [
ButtonSegment(
@@ -389,35 +402,6 @@ class _CreatePageState extends State<CreatePage> {
}
}
/// 真实发布入口(T3-17):本页其余部分是 AI 创作模拟(M4),社区发帖
/// 走这里 push 的发布页。
class _ComposeEntryCard extends StatelessWidget {
const _ComposeEntryCard({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SectionCard(
padding: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
leading: const CircleAvatar(
backgroundColor: AppColors.surfaceTint,
child: Icon(Icons.edit_outlined, color: AppColors.primary),
),
title: const Text(
'发布动态',
style: TextStyle(fontWeight: FontWeight.w800),
),
subtitle: const Text('写点文字、配上照片,分享给宠友'),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
class _UploadCard extends StatelessWidget {
const _UploadCard({
required this.uploaded,
+138 -434
View File
@@ -1,277 +1,42 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/feed_skeleton.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/post_card.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_display.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
import 'package:patbond_flutter/features/community/feed_exposure.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum HomeSegment { feed, services }
/// 首页 TabFeed 段自 T3-14 起消费 [CommunityController] 真实数据
/// (四态 + 游标翻页 + 聚合曝光埋点);问候语自 T3.5-10 起用真实展示名。
///
/// **刻意保留的 demo 占位**ADR-022 决策 D3.5-1 钉死范围,留待对应里程碑;
/// 在此登记以免实测重复反馈):
/// - **天气条与地区选择**[_WeatherStatusBar] / [_AreaPickerSheet] /
/// [_WeatherDetailsSheet]):需接外部天气服务(含 API key 与配额管理),
/// 不属本迭代。
/// - **story 环的「柴犬圈 / 猫咪圈 / 救助站」**([_StoryRow]):实为话题,
/// ADR-018 已把话题剪出 M3 范围;环内「发布」是真入口。
/// - **促销卡「新用户首单立减 ¥20」**[_PromoCard]):属 M5 服务域
/// (优惠/订单能力尚不存在)。
/// - **搜索框与「本地服务」段**:搜索只过滤已加载页(契约无检索端点);
/// 服务商数据为 demo 常量,同属 M5。
class HomePage extends StatefulWidget {
const HomePage({
required this.appState,
required this.communityController,
required this.onOpenPost,
required this.onOpenServices,
required this.onOpenCompose,
required this.onOpenCreate,
super.key,
this.profileController,
this.onOpenPost,
this.feedAnalytics,
this.isActive = true,
});
final AppState appState;
/// Feed 数据源(Tab 级单例,app.dart 装配注入)。
final CommunityController communityController;
/// 展示名数据源(T3.5-10):与资料页**共用同一控制器**,
/// 故改昵称后两处一起变,且全程只发一次 `/me`。
/// 未注入或资料尚未到手时问候语不带名字(不编造「豆豆」这类假名)。
final ProfileController? profileController;
final ValueChanged<PostModel> onOpenPost;
final ValueChanged<bool> onOpenServices;
/// 发布页入口(T3-17story 环「发布」与空态 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 是否为当前可见 TabIndexedStack 各 Tab 常驻构建,
/// 由主壳传入以驱动浏览段开/结算)。
final bool isActive;
final VoidCallback onOpenCreate;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
class _HomePageState extends State<HomePage> {
HomeSegment segment = HomeSegment.feed;
String query = '';
String sort = '综合';
/// 当前 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);
// 展示名变化(资料首次到手 / 用户改昵称后返回)即重建问候语。
widget.profileController?.addListener(_onProfileChanged);
// 主壳挂载即预取(pets 先例);重登后控制器已 reset 回 initial。
// 首屏自动预取不计入浏览段 refreshCount(非用户动作)。
if (_feed.phase == FeedPhase.initial) {
_refreshFeed(userInitiated: false);
}
if (_feedSurfaceVisible) _startSegment();
}
void _onProfileChanged() {
if (mounted) setState(() {});
}
@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();
widget.profileController?.removeListener(_onProfileChanged);
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 {
List<PostModel> get filteredPosts {
final keyword = query.trim().toLowerCase();
if (keyword.isEmpty) return _feed.feed;
return _feed.feed.where((card) {
return card.contentPreview.toLowerCase().contains(keyword) ||
(card.title?.toLowerCase().contains(keyword) ?? false) ||
authorDisplayName(card.author).toLowerCase().contains(keyword);
if (keyword.isEmpty) return widget.appState.posts;
return widget.appState.posts.where((post) {
return post.content.toLowerCase().contains(keyword) ||
post.authorName.toLowerCase().contains(keyword) ||
post.tags.any((tag) => tag.toLowerCase().contains(keyword));
}).toList();
}
@@ -330,32 +95,10 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _feed,
builder: (context, _) {
// 点赞/收藏对账失败的一次性 SnackBar(§3.5 回滚提示;与详情页
// 共用 controller 的 toggleError 消费口,先消费者清空)。
if (_feed.toggleError != null) {
_feed.clearToggleError();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('操作失败,请重试')));
});
}
// ready 后(含翻页追加)补一次可见性扫描:无滚动也能记首屏曝光。
if (_viewSegment != null && _feed.phase == FeedPhase.ready) {
_scheduleVisibilityScan();
}
return NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: RefreshIndicator(
onRefresh: () => segment == HomeSegment.feed
? _refreshFeed(userInitiated: true)
: Future<void>.delayed(const Duration(milliseconds: 500)),
return RefreshIndicator(
onRefresh: () async =>
Future<void>.delayed(const Duration(milliseconds: 500)),
child: ListView(
key: _listKey,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
_WeatherStatusBar(
@@ -366,10 +109,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
const SizedBox(height: 12),
_PetGreetingCard(
greeting: greeting,
// T3.5-10:真实展示名(`nickname ?? username`,与资料页
// 同源同规则)取代硬编码的 demo 宠物名「豆豆」。
// 资料未到手时为 null → 只问候不称名,不编造假名。
displayName: widget.profileController?.displayName,
petName: widget.appState.pet.name,
petAvatar: widget.appState.pet.avatarUrl,
advice: widget.appState.locationWeather.petAdvice,
),
@@ -397,24 +137,40 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
],
selected: {segment},
showSelectedIcon: false,
onSelectionChanged: (value) => _onSegmentChanged(value.first),
onSelectionChanged: (value) {
setState(() => segment = value.first);
},
),
const SizedBox(height: 18),
if (segment == HomeSegment.feed) ...[
_StoryRow(onCreate: widget.onOpenCompose),
_StoryRow(onCreate: widget.onOpenCreate),
const SizedBox(height: 18),
_PromoCard(onTap: () => widget.onOpenServices(true)),
const SizedBox(height: 18),
..._feedSection(),
if (filteredPosts.isEmpty)
const EmptyState(message: '没有找到相关动态')
else
...filteredPosts.map(
(post) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _PostCard(
post: post,
onTap: () => widget.onOpenPost(post),
onLike: () => widget.appState.updatePost(
post.copyWith(
hasLiked: !post.hasLiked,
likes: post.hasLiked ? post.likes - 1 : post.likes + 1,
),
),
),
),
),
] else ...[
_CategoryGrid(onTap: (_) => widget.onOpenServices(false)),
const SizedBox(height: 18),
Row(
children: [
Text(
'附近推荐',
style: Theme.of(context).textTheme.titleLarge,
),
Text('附近推荐', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
for (final item in ['综合', '距离', '评分'])
Padding(
@@ -445,143 +201,10 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
],
],
),
),
);
},
);
}
// ---- Feed 段四态渲染 ----
List<Widget> _feedSection() {
switch (_feed.phase) {
case FeedPhase.initial:
case FeedPhase.loading:
return const [
FeedSkeleton(),
SizedBox(height: 16),
FeedSkeleton(),
SizedBox(height: 16),
FeedSkeleton(),
];
case FeedPhase.error:
return [
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
InlineErrorBanner(
message: feedLoadErrorMessage(_feed.lastError),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => _refreshFeed(userInitiated: true),
child: const Text('重试'),
),
],
),
),
];
case FeedPhase.ready:
if (_feed.feed.isEmpty) {
return [
EmptyStateIllustration(
icon: Icons.forum_outlined,
title: '还没有动态',
description: '关注的毛孩子们还没发帖,去逛逛话题吧',
ctaLabel: '发布第一条',
onCtaPressed: widget.onOpenCompose,
),
];
}
final cards = _visibleCards;
_pruneCardKeys(cards);
if (cards.isEmpty) {
return const [EmptyState(message: '没有找到相关动态')];
}
return [
for (final card in cards)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: KeyedSubtree(
key: _cardKeys.putIfAbsent(card.id, GlobalKey.new),
// 点赞/收藏经共享 ToggleSyncsource=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),
),
),
);
}
}
}
/// **刻意保留的 demo 占位**ADR-022 决策 D3.5-1):定位与天气需接外部
/// 服务(含 API key 与配额管理),本迭代不做;地区选择只改本机 demo 数据。
class _WeatherStatusBar extends StatelessWidget {
const _WeatherStatusBar({
required this.weather,
@@ -671,23 +294,14 @@ class _WeatherStatusBar extends StatelessWidget {
class _PetGreetingCard extends StatelessWidget {
const _PetGreetingCard({
required this.greeting,
required this.displayName,
required this.petName,
required this.petAvatar,
required this.advice,
});
final String greeting;
/// 当前用户展示名;null 即资料未到手(问候语退化为不称名形态)。
final String? displayName;
/// **刻意保留的 demo 占位**:卡右侧大图仍是 demo 宠物图(用户头像与宠物
/// 头像本单已真实化,但「首页该显示哪只宠物」需要一个『当前宠物』概念,
/// 尚不存在——不在 ADR-022 范围内)。
final String petName;
final String petAvatar;
/// **刻意保留的 demo 占位**:养宠建议来自 demo 天气数据(外部天气服务
/// 未接,ADR-022 决策 D3.5-1)。
final String advice;
@override
@@ -730,9 +344,7 @@ class _PetGreetingCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName == null
? '$greeting 👋'
: '$greeting$displayName 👋',
'$greeting$petName 👋',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -927,9 +539,6 @@ Color _weatherColor(WeatherCondition condition) {
};
}
/// **刻意保留的 demo 占位**ADR-022 决策 D3.5-1):「柴犬圈 / 猫咪圈 /
/// 救助站」三个圈子实为**话题**,ADR-018 已把话题能力剪出 M3 范围,
/// 留待话题里程碑。首格「发布」是真入口(T3-17 起 push 真实发布页)。
class _StoryRow extends StatelessWidget {
const _StoryRow({required this.onCreate});
@@ -1000,8 +609,6 @@ class _StoryRow extends StatelessWidget {
}
}
/// **刻意保留的 demo 占位**ADR-022 决策 D3.5-1):促销文案与「去使用」
/// 属 **M5 服务域**——优惠券与订单能力尚不存在,当前点按只跳本地服务段。
class _PromoCard extends StatelessWidget {
const _PromoCard({required this.onTap});
@@ -1054,6 +661,103 @@ class _PromoCard extends StatelessWidget {
}
}
class _PostCard extends StatelessWidget {
const _PostCard({
required this.post,
required this.onTap,
required this.onLike,
});
final PostModel post;
final VoidCallback onTap;
final VoidCallback onLike;
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
RemoteImage(
url: post.authorAvatar,
width: 38,
height: 38,
borderRadius: BorderRadius.circular(19),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.authorName,
style: const TextStyle(fontWeight: FontWeight.w700),
),
Text(
'${post.time} · ${post.breedTag}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const Icon(Icons.more_horiz, color: AppColors.muted),
],
),
),
AspectRatio(
aspectRatio: 4 / 3,
child: RemoteImage(url: post.mainImage),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Row(
children: [
ActionChip(
avatar: Icon(
post.hasLiked
? Icons.favorite
: Icons.favorite_border,
size: 17,
color: post.hasLiked ? Colors.red : AppColors.primary,
),
label: Text('${post.likes}'),
onPressed: onLike,
),
const SizedBox(width: 8),
Chip(
avatar: const Icon(Icons.chat_bubble_outline, size: 16),
label: Text('${post.comments.length}'),
),
const Spacer(),
const Icon(Icons.share_outlined, color: AppColors.muted),
],
),
],
),
),
],
),
),
);
}
}
class _CategoryGrid extends StatelessWidget {
const _CategoryGrid({required this.onTap});
+16 -111
View File
@@ -1,83 +1,27 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import 'package:patbond_flutter/features/community/post_compose_page.dart';
import 'package:patbond_flutter/features/create/create_page.dart';
import 'package:patbond_flutter/features/home/home_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
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/post/post_detail_page.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/features/profile/profile_page.dart';
import 'package:patbond_flutter/features/services/services_page.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
class MainShellPage extends StatefulWidget {
const MainShellPage({
required this.appState,
required this.petsController,
required this.communityController,
required this.profileController,
super.key,
this.currentUserId,
this.petAnalytics,
this.healthRecordAnalytics,
this.feedAnalytics,
this.interactionAnalytics,
this.postAnalytics,
this.mediaUploaderFactory,
this.avatarUploaderBuilder,
this.pageViewTracker,
this.onLogout,
});
final AppState appState;
/// 宠物档案状态(T2-11 拆出的独立 pets feature;档案 Tab 数据源)。
final PetsController petsController;
/// 社区状态(T3-12 数据层;首页 Feed segment 数据源,T3-14 接线)。
final CommunityController communityController;
/// 本人资料与社区数字(T3.5-08);资料 Tab 与首页问候语共用。
final ProfileController profileController;
/// 当前登录用户 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;
/// 头像上传构造口(T3.5-08/09:资料编辑页与宠物详情页)。
final AvatarUploaderBuilder? avatarUploaderBuilder;
/// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。
final PageViewTracker? pageViewTracker;
@@ -94,12 +38,11 @@ class _MainShellPageState extends State<MainShellPage> {
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
/// Tab 索引 → pageName 枚举(03 号评估 §3.2 映射表;档案 Tab 自 T2-12
/// 起为真实宠物列表,页名由 pet_archive 改报 pet_list06 §5.2 字典 v2)。
/// Tab 索引 → pageName 枚举(03 号评估 §3.2 映射表)。
static const _tabPages = [
AnalyticsPageName.home,
AnalyticsPageName.create,
AnalyticsPageName.petList,
AnalyticsPageName.petArchive,
AnalyticsPageName.services,
AnalyticsPageName.profile,
];
@@ -124,44 +67,16 @@ class _MainShellPageState extends State<MainShellPage> {
widget.pageViewTracker?.reportTab(_tabPages[3]);
}
/// 帖子详情(T3-15:真实数据整页;Feed 卡片与详情共享
/// communityController,互动状态跨页一致)。
void openPost(String postId) {
void openPost(PostModel post) {
Navigator.of(context).push(
MaterialPageRoute<void>(
settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName),
builder: (context) => PostDetailPage(
controller: widget.communityController,
postId: postId,
currentUserId: widget.currentUserId,
analytics: widget.interactionAnalytics,
),
builder: (context) =>
PostDetailPage(appState: widget.appState, postId: post.id),
),
);
}
/// 发布页(T3-17:真实发布链路)。发布成功后回首页 Feed 并整体刷新,
/// 新帖按 `(published_at DESC, id DESC)` 落在首位(跨客户端同一序)。
Future<void> openCompose(PostEntryPoint entryPoint) async {
final published = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
settings: RouteSettings(name: AnalyticsPageName.postForm.pageName),
builder: (context) => PostComposePage(
controller: widget.communityController,
entryPoint: entryPoint,
analytics: widget.postAnalytics,
uploaderFactory: widget.mediaUploaderFactory,
),
),
);
if (!mounted || published != true) return;
selectTab(0);
unawaited(widget.communityController.refresh());
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已发布,去首页看看吧 🐾')));
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -176,35 +91,25 @@ class _MainShellPageState extends State<MainShellPage> {
final pages = [
HomePage(
appState: widget.appState,
communityController: widget.communityController,
profileController: widget.profileController,
feedAnalytics: widget.feedAnalytics,
isActive: currentIndex == 0,
onOpenServices: openServices,
onOpenCompose: () => openCompose(PostEntryPoint.feed),
onOpenPost: openPost,
onOpenServices: openServices,
onOpenCreate: () => selectTab(1),
),
CreatePage(
appState: widget.appState,
// T3-17:发布半边已真实化(发布页 push),AI 生成模拟原样留 M4。
onOpenCompose: () => openCompose(PostEntryPoint.createTab),
),
PetsPage(
controller: widget.petsController,
analytics: widget.petAnalytics,
healthAnalytics: widget.healthRecordAnalytics,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
onPublished: (post) {
selectTab(0);
WidgetsBinding.instance.addPostFrameCallback(
(_) => openPost(post),
);
},
),
PetsPage(appState: widget.appState),
ServicesPage(
showPersonal: showPersonalServices,
locationWeather: widget.appState.locationWeather,
),
ProfilePage(
appState: widget.appState,
controller: widget.profileController,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
onLogout: widget.onLogout,
),
ProfilePage(appState: widget.appState, onLogout: widget.onLogout),
];
return Scaffold(
@@ -1,321 +0,0 @@
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_date_picker.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: AppDateFieldTrailing(
firstDate: DateTime.now(),
lastDate: DateTime(DateTime.now().year + 5),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() {
_dueDate = value;
_dueError = null;
});
},
),
enabled: !_submitting,
onTap: () async {
final now = DateTime.now();
final value = await pickAppDate(
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,
),
],
),
),
);
}
}
-518
View File
@@ -1,518 +0,0 @@
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_date_picker.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}`
/// pendingcompleted completedAtpendingdismissed
/// 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,
// pendingcompleted 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,
// pendingdismissed 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() {
// CTA05 §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 pickAppDate(
context: context,
initialDate: _date,
firstDate: DateTime(1990),
lastDate: now,
);
if (value != null && mounted) {
setState(() => _date = value);
}
},
trailing: AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime.now(),
onToday: (value) => setState(() => _date = value),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_date),
child: const Text('确认完成'),
),
],
);
}
}
@@ -1,351 +0,0 @@
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 conflictrecordType=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,
),
],
),
),
);
}
}
@@ -1,422 +0,0 @@
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_date_picker.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
/// UTCISO 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: AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() => _occurredDate = value);
},
),
enabled: !_submitting,
onTap: () async {
final now = DateTime.now();
final value = await pickAppDate(
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.4RecordTypeDot 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,
),
),
],
),
),
),
);
}
}
-368
View File
@@ -1,368 +0,0 @@
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-14cursor 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_form06 §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.22026 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,
),
),
],
],
),
],
),
),
),
);
}
}
@@ -1,158 +0,0 @@
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
/// health_record 06 §1.4 v2
/// 24 §2.2沿 13 §3.1
///
///
/// T2-13 + viewedT2-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_errorserver_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] 0M2 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,
});
}
}
@@ -1,211 +0,0 @@
/// / / /
/// /
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.355.00 54.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 plannedOncompleted 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.22026 9
String healthEventMonthHeader(DateTime occurredAt) {
final local = occurredAt.toLocal();
return '${local.year}${local.month}';
}
/// M3.5-03
///
///
/// 2026-04 ¥0
/// `summary.monthlyExpense.month` ISO year-month `2026-09`
/// `tz`
///
/// [month] `2026-09` 4~5 9
/// 2026-09
/// 1 12 退
String monthlyExpenseCardLabel(String month, {DateTime? now}) {
final match = RegExp(r'^(\d{4})-(\d{2})$').firstMatch(month);
if (match == null) return '本月花费';
final year = int.parse(match.group(1)!);
final monthNo = int.parse(match.group(2)!);
if (monthNo < 1 || monthNo > 12) return '本月花费';
final currentYear = (now ?? DateTime.now()).year;
if (year != currentYear) return '$year/$monthNo 花费';
return '$monthNo 月花费';
}
/// 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)}',
};
-30
View File
@@ -1,30 +0,0 @@
/// amountCentsUI
/// 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;
}
-83
View File
@@ -1,83 +0,0 @@
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 §4pet_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_errorserver_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,
});
}
}
-837
View File
@@ -1,837 +0,0 @@
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/avatar_upload_sheet.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/community/community_models.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+ /
/// notFound40401
///
/// 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,
this.avatarUploaderBuilder,
});
final PetsController controller;
final String petId;
final PetAnalytics? analytics;
final HealthRecordAnalytics? healthAnalytics;
/// T3.5-09null
/// `app.dart`
final AvatarUploaderBuilder? avatarUploaderBuilder;
@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=pendingdue_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 40300caregiver/viewer
bool get _canEdit =>
_phase == _DetailPhase.ready && _pet?.myRole == PetRole.owner;
/// WRITEowner + caregiverviewer
bool get _canWriteRecords => _pet?.myRole != PetRole.viewer;
/// **WRITE**ADR-022 D3.5-3
/// /owner + caregiver viewer
///
/// [_canEdit]MANAGE owner****
/// avatarAssetId WRITE
/// MANAGE PATCH
bool get _canEditAvatar =>
_phase == _DetailPhase.ready &&
_pet?.myRole != PetRole.viewer &&
widget.avatarUploaderBuilder != null;
/// /
Future<void> _onAvatarTap() async {
final pet = _pet;
if (pet == null || !_canEditAvatar) return;
if (pet.avatarUrl == null) {
await _uploadAvatar();
return;
}
final action = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('更换头像'),
onTap: () => Navigator.pop(context, 'replace'),
),
ListTile(
leading: const Icon(
Icons.delete_outline,
color: AppColors.errorDark,
),
title: const Text(
'移除头像',
style: TextStyle(color: AppColors.errorDark),
),
onTap: () => Navigator.pop(context, 'clear'),
),
],
),
),
);
if (!mounted || action == null) return;
if (action == 'replace') {
await _uploadAvatar();
} else {
// null= JSON
await _patchAvatar(const PatchField<String>.clear(), '已移除头像');
}
}
Future<void> _uploadAvatar() async {
final builder = widget.avatarUploaderBuilder;
if (builder == null) return;
final assetId = await showAvatarUploadSheet(
context,
builder: builder,
// 404/40405
purpose: MediaPurpose.petAvatar,
);
if (assetId == null || !mounted) return;
await _patchAvatar(PatchField<String>.value(assetId), '头像已更新');
}
/// PATCH `version` + `avatarAssetId`**
/// ** WRITE MANAGEcaregiver
/// 403
Future<void> _patchAvatar(PatchField<String> intent, String okMessage) async {
final pet = _pet;
if (pet == null) return;
try {
final updated = await widget.controller.updatePet(
pet.id,
UpdatePetRequest(version: pet.version, avatarAssetId: intent),
);
if (!mounted) return;
setState(() {
_pet = updated;
_phase = _DetailPhase.ready;
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(okMessage)));
} on PetVersionConflictException catch (error) {
// 40902 version
//
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petAvatarSaveErrorMessage(error))));
await _load();
} on ApiException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petAvatarSaveErrorMessage(error))));
}
}
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: [
// T3.5-09 URL退
// MediaUploader pet_avatarWRITE
PetAvatar(
size: PetAvatarSize.xl,
url: pet.avatarUrl,
showEditBadge: _canEditAvatar,
onTap: _canEditAvatar ? _onAvatarTap : null,
semanticLabel: _canEditAvatar ? '更换宠物头像' : 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,
// M3.5-03
//
label: monthlyExpenseCardLabel(expense.month),
onTap: () => _openTimeline(pet),
),
),
],
),
);
}
}
}
/// stat-card + 15/w800 + 12 inkSoft
/// inkSoft
///
/// M3.5-03[onTap] `chevron_right`
/// 沿
/// / `chevron_right`
/// [MergeSemantics] + InkWell button
/// ¥09 tap
/// InkWell `excludeSemantics`
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 MergeSemantics(
child: 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: [
Row(
children: [
Icon(icon, size: 18, color: iconColor),
const Spacer(),
if (onTap != null)
const Icon(
Icons.chevron_right,
size: 16,
color: AppColors.muted,
),
],
),
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-cardsuccessSurface + 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),
],
),
),
),
);
}
}
-74
View File
@@ -1,74 +0,0 @@
/// / /
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 _ => '请求过于频繁,请稍后再试',
_ => '加载失败,请稍后重试',
};
/// T3.5-09 v1.4.0
/// `PATCH /pets/{petId}`
///
/// - 404/40405asset / / / ****
/// 40401
/// - 422/42203 `pet_avatar` asset uploading/failed
/// - 403/40300viewer myRole
/// - 409/40902
String petAvatarSaveErrorMessage(ApiException? error) => switch (error) {
ApiBusinessException(code: ApiCodes.mediaNotFound) => '头像已失效,请重新上传',
ApiBusinessException(code: ApiCodes.mediaNotReady) => '头像还没上传完,请稍后重试',
ApiBusinessException(code: ApiCodes.petAccessDenied) => '你没有修改该宠物头像的权限',
ApiBusinessException(code: ApiCodes.versionConflict) => '档案已被更新,请重新操作',
ApiNetworkException _ => '网络异常,请检查网络后重试',
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
_ => '头像保存失败,请稍后重试',
};
-83
View File
@@ -1,83 +0,0 @@
import 'package:patbond_flutter/core/network/api_exception.dart';
/// pets v1.2.0 8
/// 21 §1 [ApiBusinessException]
///
/// 40300viewer 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);
}
/// 40902version
///
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,
};
}
-720
View File
@@ -1,720 +0,0 @@
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_date_picker.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
/// - 沿+ errorTextonChanged
/// InlineErrorBanner SnackBar+
/// - 409/40902
/// version40903
///
/// - 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),
);
// petIndexH2
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: AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() => _birthDate = value);
},
),
enabled: !_submitting,
onTap: () async {
final now = DateTime.now();
final value = await pickAppDate(
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),
);
}
}
-858
View File
@@ -1,858 +0,0 @@
/// pets / 稿 openapi.yaml v1.4.0
/// [FormatException]
/// 便
library;
import 'package:patbond_flutter/core/models/patch_field.dart';
export 'package:patbond_flutter/core/models/patch_field.dart';
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.avatarUrl,
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),
// v1.4.0 GET URL
// ** avatarAssetId**
avatarUrl: json['avatarUrl'] 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;
/// GET URL / asset ready / null
/// null assetId
final String? avatarUrl;
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!),
};
}
/// species
/// version
///
/// **** [avatarAssetId] 沿 M2
/// null null CHECK
/// [avatarAssetId] schema **** v1.4.0
///
///
/// ****ADR-022 + `requiredLevel`
/// [avatarAssetId] WRITEowner + caregiver
/// MANAGE owner
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,
this.avatarAssetId = const PatchField<String>.absent(),
});
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;
/// asset`purpose` `pet_avatar`
final PatchField<String> avatarAssetId;
Map<String, Object?> toJson() {
final json = <String, Object?>{
'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,
};
avatarAssetId.writeTo(json, 'avatarAssetId');
return json;
}
}
///
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 / completedcancelled 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;
}
/// nulldueOn
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;
}
-121
View File
@@ -1,121 +0,0 @@
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();
}
}
+641 -233
View File
@@ -1,315 +1,723 @@
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/widgets/avatar_upload_sheet.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/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/data/demo_data.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
/// Tab T2-12 / 05 §4.1 P1
///
/// [PetsController] loading
/// empty + CTAerror + ready
/// ApiClient AppState demo
class PetsPage extends StatefulWidget {
const PetsPage({
required this.controller,
super.key,
this.analytics,
this.healthAnalytics,
this.avatarUploaderBuilder,
});
class PetsPage extends StatelessWidget {
const PetsPage({required this.appState, super.key});
final PetsController controller;
final PetAnalytics? analytics;
final AppState appState;
/// health_record T2-13
final HealthRecordAnalytics? healthAnalytics;
/// T3.5-09
final AvatarUploaderBuilder? avatarUploaderBuilder;
@override
State<PetsPage> createState() => _PetsPageState();
}
class _PetsPageState extends State<PetsPage> {
@override
void initState() {
super.initState();
// IndexedStack Tab
// reset initial
if (widget.controller.phase == PetsLoadPhase.initial) {
widget.controller.refresh();
String ageLabel(String value) {
final birthday = DateTime.tryParse(value);
if (birthday == null) return '年龄未知';
final today = DateTime.now();
var age = today.year - birthday.year;
if (today.month < birthday.month ||
(today.month == birthday.month && today.day < birthday.day)) {
age--;
}
return '${age < 0 ? 0 : age}';
}
Future<void> _openCreate(PetCreateEntryPoint entryPoint) async {
final created = await Navigator.of(context).push<Pet>(
fadePageRoute(
PetFormPage.create(
controller: widget.controller,
analytics: widget.analytics,
entryPoint: entryPoint,
),
// RouteObserver 06 §1.6 pet_form
settings: RouteSettings(name: AnalyticsPageName.petForm.pageName),
),
Future<void> editPet(BuildContext context) async {
final value = await showModalBottomSheet<PetProfile>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => EditPetSheet(pet: appState.pet),
);
if (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('已为「${created.name}」建立档案 🐾')));
}
if (value != null) await appState.updatePet(value);
}
void _openDetail(Pet pet) {
Navigator.of(context).push(
fadePageRoute(
PetDetailPage(
controller: widget.controller,
petId: pet.id,
analytics: widget.analytics,
healthAnalytics: widget.healthAnalytics,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
),
settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName),
),
Future<void> editVaccines(BuildContext context) async {
final value = await showModalBottomSheet<VaccineRecord>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => VaccineSheet(record: appState.vaccines),
);
if (value != null) await appState.updateVaccines(value);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.controller,
builder: (context, _) {
final controller = widget.controller;
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);
}
},
);
}
final pet = appState.pet;
final vaccines = appState.vaccines;
final progress = vaccines.totalDoses == 0
? 0.0
: vaccines.completedDoses / vaccines.totalDoses;
Widget _petList(List<Pet> pets) {
return RefreshIndicator(
onRefresh: widget.controller.refresh,
child: ListView(
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
children: [
Row(
Column(
children: [
Text('我的宠物', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
TextButton.icon(
onPressed: () => _openCreate(PetCreateEntryPoint.petList),
icon: const Icon(Icons.add, size: 18),
label: const Text('添加'),
Stack(
children: [
InkWell(
onTap: () => editPet(context),
borderRadius: BorderRadius.circular(54),
child: RemoteImage(
url: pet.avatarUrl,
width: 104,
height: 104,
borderRadius: BorderRadius.circular(52),
),
),
Positioned(
right: 0,
bottom: 0,
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),
for (final pet in pets) ...[
_PetCard(pet: pet, onTap: () => _openDetail(pet)),
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),
],
_AddPetCard(onTap: () => _openCreate(PetCreateEntryPoint.petList)),
OutlinedButton.icon(
onPressed: () => editPet(context),
icon: const Icon(Icons.settings_outlined, size: 17),
label: const Text('编辑资料'),
),
],
),
);
}
}
/// InlineErrorBanner + 05 §4.2
class _LoadErrorView extends StatelessWidget {
const _LoadErrorView({required this.message, required this.onRetry});
final String message;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(height: 24),
Row(
children: [
InlineErrorBanner(message: message),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('重试')),
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: '已记录',
),
],
);
}
}
/// 05 §4.1 lg + + + chevron active
/// TagPill
class _PetCard extends StatelessWidget {
const _PetCard({required this.pet, required this.onTap});
class _StatCard extends StatelessWidget {
const _StatCard({
required this.icon,
required this.color,
required this.value,
required this.label,
this.onTap,
});
final Pet pet;
final VoidCallback onTap;
final IconData icon;
final Color color;
final String value;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.all(14),
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: Column(
children: [
Icon(icon, color: color),
const SizedBox(height: 6),
Text(value, style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 2),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
),
),
),
);
}
}
class _TimelineTile extends StatelessWidget {
const _TimelineTile({
required this.icon,
required this.title,
required this.subtitle,
required this.status,
});
final IconData icon;
final String title;
final String subtitle;
final String status;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SectionCard(
child: Row(
children: [
// T3.5-09 URL退
PetAvatar(size: PetAvatarSize.lg, url: pet.avatarUrl),
CircleAvatar(child: Icon(icon, color: AppColors.primary, size: 20)),
const SizedBox(width: 12),
Expanded(
child: Column(
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,
),
title,
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
),
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),
TagPill(status, color: AppColors.success),
],
),
),
),
);
}
}
/// 线05 §4.1border 1.5px dashedradius 24 64
class _AddPetCard extends StatelessWidget {
const _AddPetCard({required this.onTap});
class EditPetSheet extends StatefulWidget {
const EditPetSheet({required this.pet, super.key});
final VoidCallback onTap;
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 CustomPaint(
painter: _DashedBorderPainter(
color: AppColors.border,
strokeWidth: 1.5,
radius: AppRadius.xl,
return Padding(
padding: EdgeInsets.fromLTRB(
20,
10,
20,
MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: SizedBox(
height: 64,
child: Material(
color: Colors.transparent,
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: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: const Center(
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(
' 添加宠物',
style: TextStyle(
color: AppColors.primaryStrong,
fontSize: 14,
fontWeight: FontWeight.w600,
'$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,
);
}
}
class _DashedBorderPainter extends CustomPainter {
const _DashedBorderPainter({
required this.color,
required this.strokeWidth,
required this.radius,
});
final Color color;
final double strokeWidth;
final double radius;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke;
final path = Path()
..addRRect(
RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)),
},
),
);
const dashWidth = 6.0;
const dashGap = 4.0;
for (final metric in path.computeMetrics()) {
var distance = 0.0;
while (distance < metric.length) {
canvas.drawPath(
metric.extractPath(distance, distance + dashWidth),
paint,
}),
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),
),
),
);
distance += dashWidth + dashGap;
}
}
}
@override
bool shouldRepaint(_DashedBorderPainter oldDelegate) =>
color != oldDelegate.color ||
strokeWidth != oldDelegate.strokeWidth ||
radius != oldDelegate.radius;
}
-312
View File
@@ -1,312 +0,0 @@
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));
}
}
@@ -1,534 +0,0 @@
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_date_picker.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/failedrecordType=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: AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: allowFuture
? DateTime(DateTime.now().year + 5)
: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
onPicked(value);
},
),
enabled: !_submitting,
onTap: () async {
final now = DateTime.now();
final picked = await pickAppDate(
context: context,
initialDate: value ?? now,
firstDate: DateTime(1990),
lastDate: allowFuture ? DateTime(now.year + 5) : now,
);
if (picked != null && mounted) {
_markStarted();
onPicked(picked);
}
},
);
}
}
@@ -1,605 +0,0 @@
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_date_picker.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),
),
trailing: AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: allowFuture
? DateTime(DateTime.now().year + 5)
: DateTime.now(),
onToday: (picked) => setState(() {
onPicked(picked);
_dateError = null;
}),
),
onTap: () async {
final now = DateTime.now();
final picked = await pickAppDate(
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('确认完成')),
],
);
}
}
-294
View File
@@ -1,294 +0,0 @@
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_date_picker.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 UTCISO 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: AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() => _measuredDate = value);
},
),
enabled: !_submitting,
onTap: () async {
final now = DateTime.now();
final value = await pickAppDate(
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,
),
],
),
),
);
}
}
-294
View File
@@ -1,294 +0,0 @@
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-13cursor
/// [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_form06 §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
@@ -1,137 +0,0 @@
import 'package:flutter/foundation.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/auth_repository.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
/// 沿 pets/community
enum ProfileLoadPhase { initial, loading, ready, error }
/// `/me` ****
/// error
enum ProfileStatsPhase { loading, ready, error }
/// T3.5-08
///
///
/// - `GET /api/v1/me` / error
/// - `GET /api/v1/me/community-stats` /
/// - `GET /api/v1/users/{me}/follow-stats` /
///
/// T3.5-10 [displayName]
/// `/me`
class ProfileController extends ChangeNotifier {
ProfileController({
required AuthRepository authRepository,
required CommunityRepository communityRepository,
}) : _auth = authRepository,
_community = communityRepository;
final AuthRepository _auth;
final CommunityRepository _community;
ProfileLoadPhase _phase = ProfileLoadPhase.initial;
ProfileStatsPhase _statsPhase = ProfileStatsPhase.loading;
UserProfile? _profile;
CommunityStats? _communityStats;
FollowStats? _followStats;
ApiException? _lastError;
bool _disposed = false;
ProfileLoadPhase get phase => _phase;
ProfileStatsPhase get statsPhase => _statsPhase;
/// ready null
UserProfile? get profile => _profile;
CommunityStats? get communityStats => _communityStats;
FollowStats? get followStats => _followStats;
ApiException? get lastError => _lastError;
/// `nickname ?? username` null
String? get displayName => _profile?.displayName;
/// / error +
///
/// ****`_phase` ready
///
Future<void> refresh() async {
if (_profile == null) {
_phase = ProfileLoadPhase.loading;
}
_lastError = null;
_notify();
try {
_profile = await _auth.me();
_phase = ProfileLoadPhase.ready;
} on ApiException catch (error) {
_lastError = error;
if (_profile == null) {
_phase = ProfileLoadPhase.error;
_notify();
return;
}
}
_notify();
await refreshStats();
}
///
Future<void> refreshStats() async {
final userId = _profile?.userId;
if (userId == null) return;
_statsPhase = ProfileStatsPhase.loading;
_notify();
try {
// error
// +
//
final community = await _community.getMyCommunityStats();
final follow = await _community.getFollowStats(userId);
_communityStats = community;
_followStats = follow;
_statsPhase = ProfileStatsPhase.ready;
} on ApiException {
_statsPhase = ProfileStatsPhase.error;
}
_notify();
}
/// [UpdateMeRequest]
///
/// ** patch ** patch 400/40000
/// 200
/// `avatarUrl`
///
Future<UserProfile?> save(UpdateMeRequest request) async {
if (request.isEmpty) return _profile;
final updated = await _auth.updateMe(request);
_profile = updated;
_phase = ProfileLoadPhase.ready;
_notify();
return updated;
}
/// initial /
/// 沿 `PetsController.reset`
void reset() {
_phase = ProfileLoadPhase.initial;
_statsPhase = ProfileStatsPhase.loading;
_profile = null;
_communityStats = null;
_followStats = null;
_lastError = null;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
-47
View File
@@ -1,47 +0,0 @@
import 'package:patbond_flutter/core/network/api_exception.dart';
/// /沿 `petLoadErrorMessage`
/// message
String profileLoadErrorMessage(ApiException? error) => switch (error) {
ApiNetworkException _ => '网络异常,请检查网络后重试',
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
_ => '加载失败,请稍后重试',
};
/// v1.4.0 `PATCH /api/v1/me`
///
/// | | | |
/// | --- | --- | --- |
/// | 40000 | / patch UUID | |
/// | 40405 | asset /// | |
/// | 42203 | asset uploading/failed | |
///
/// 40000 / patch /
/// assetId 40000
///
String profileSaveErrorMessage(ApiException? error) => switch (error) {
ApiBusinessException(code: ApiCodes.paramError) => '填写内容不符合要求,请检查后重试',
ApiBusinessException(code: ApiCodes.mediaNotFound) => '头像已失效,请重新上传',
ApiBusinessException(code: ApiCodes.mediaNotReady) => '头像还没上传完,请稍后重试',
ApiNetworkException _ => '网络异常,请检查网络后重试',
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
_ => '保存失败,请稍后重试',
};
/// `ck_users_nickname`btrim + 1~32
///
/// ****`String.characters` `runes`
/// `char_length` UTF-16 `String.length` 32 emoji
/// UTF-16 64
///
/// null errorText
String? validateNickname(String raw) {
// btrim btrim trim
final trimmed = raw.trim();
if (trimmed.isEmpty) {
// 400/40000
return '昵称不能为空,如需清除请用下方「清除昵称」';
}
if (trimmed.runes.length > 32) return '昵称最多 32 个字';
return null;
}
-335
View File
@@ -1,335 +0,0 @@
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/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.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_models.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/features/profile/profile_display.dart';
import 'package:patbond_flutter/widgets/common.dart';
/// T3.5-08 +
///
/// **PATCH ** v1.4.0 `UpdateMeRequest`
///
/// | | |
/// | --- | --- |
/// | | `nickname` **** |
/// | | `nickname: "新值"` |
/// | | `nickname: null` |
/// | | `avatarAssetId` **** |
/// | | `avatarAssetId: "<ready assetId>"` |
/// | | `avatarAssetId: null` |
///
/// JSON ** vs null**
/// null
/// ****[PatchField]
///
class ProfileEditPage extends StatefulWidget {
const ProfileEditPage({
required this.controller,
super.key,
this.avatarUploaderBuilder,
});
final ProfileController controller;
/// null
/// `app.dart`
final AvatarUploaderBuilder? avatarUploaderBuilder;
@override
State<ProfileEditPage> createState() => _ProfileEditPageState();
}
class _ProfileEditPageState extends State<ProfileEditPage> {
late final TextEditingController _nickname;
///
late final UserProfile _initial;
/// null
/// null
bool _nicknameCleared = false;
/// absent / clear / assetId
PatchField<String> _avatarIntent = const PatchField<String>.absent();
///
String? _pendingAvatarUrl;
String? _nicknameError;
bool _saving = false;
ApiException? _saveError;
@override
void initState() {
super.initState();
_initial = widget.controller.profile!;
// ** DB ** displayName退 username
//
// /me 退
_nickname = TextEditingController(text: _initial.nickname ?? '');
_nickname.addListener(_onNicknameChanged);
}
@override
void dispose() {
_nickname.dispose();
super.dispose();
}
void _onNicknameChanged() {
// `_hasChanges`
//
//
setState(() {
if (_nicknameCleared && _nickname.text.isNotEmpty) {
_nicknameCleared = false;
}
_nicknameError = null;
});
}
/// > >
PatchField<String> get _nicknamePatch {
if (_nicknameCleared) {
// patch
//
return _initial.nickname == null
? const PatchField<String>.absent()
: const PatchField<String>.clear();
}
final trimmed = _nickname.text.trim();
if (trimmed.isEmpty) return const PatchField<String>.absent();
if (trimmed == _initial.nickname) return const PatchField<String>.absent();
return PatchField<String>.value(trimmed);
}
UpdateMeRequest get _request =>
UpdateMeRequest(nickname: _nicknamePatch, avatarAssetId: _avatarIntent);
bool get _hasChanges => !_request.isEmpty;
/// > >
String? get _shownAvatarUrl {
if (_avatarIntent.isClear) return null;
return _pendingAvatarUrl ?? _initial.avatarUrl;
}
bool get _hasAvatarNow => _shownAvatarUrl != null;
Future<void> _pickAvatar() async {
final builder = widget.avatarUploaderBuilder;
if (builder == null) return;
final assetId = await showAvatarUploadSheet(
context,
builder: builder,
purpose: MediaPurpose.userAvatar,
);
if (assetId == null || !mounted) return;
setState(() {
_avatarIntent = PatchField<String>.value(assetId);
// 沿访 URL complete url
// /me avatarUrl
//
_pendingAvatarUrl = null;
});
}
void _clearAvatar() {
setState(() {
_avatarIntent = const PatchField<String>.clear();
_pendingAvatarUrl = null;
});
}
void _clearNickname() {
setState(() {
_nicknameCleared = true;
_nicknameError = null;
_nickname.clear();
});
}
Future<void> _save() async {
//
final raw = _nickname.text;
if (!_nicknameCleared && raw.trim().isNotEmpty) {
final error = validateNickname(raw);
if (error != null) {
setState(() => _nicknameError = error);
return;
}
}
final request = _request;
if (request.isEmpty) {
// patch 400/40000 200
Navigator.of(context).pop(false);
return;
}
setState(() {
_saving = true;
_saveError = null;
});
try {
await widget.controller.save(request);
if (!mounted) return;
Navigator.of(context).pop(true);
} on ApiException catch (error) {
if (!mounted) return;
setState(() {
_saving = false;
_saveError = error;
});
}
}
@override
Widget build(BuildContext context) {
final canUploadAvatar = widget.avatarUploaderBuilder != null;
return Scaffold(
appBar: AppBar(title: const Text('编辑资料')),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 30),
children: [
if (_saveError != null) ...[
InlineErrorBanner(message: profileSaveErrorMessage(_saveError)),
const SizedBox(height: 16),
],
if (canUploadAvatar) ...[
Center(
child: Column(
children: [
_AvatarPreview(
url: _shownAvatarUrl,
pendingAssetId: _avatarIntent.valueOrNull,
onTap: _saving ? null : _pickAvatar,
),
const SizedBox(height: 10),
TextButton.icon(
onPressed: _saving ? null : _pickAvatar,
icon: const Icon(Icons.photo_camera_outlined, size: 18),
label: const Text('更换头像'),
),
if (_hasAvatarNow)
TextButton(
onPressed: _saving ? null : _clearAvatar,
child: const Text(
'清除头像',
style: TextStyle(color: AppColors.errorDark),
),
),
if (_avatarIntent.isClear)
const Text(
'保存后将移除头像',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
],
),
),
const SizedBox(height: 20),
],
AppTextField(
label: '昵称',
controller: _nickname,
enabled: !_saving,
errorText: _nicknameError,
// 退
//
helperText: _initial.nickname == null
? '未设置,当前展示为用户名「${_initial.username}'
: '1~32 个字',
textInputAction: TextInputAction.done,
onSubmitted: (_) => _saving ? null : _save(),
),
if (_initial.nickname != null)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _saving ? null : _clearNickname,
child: const Text(
'清除昵称',
style: TextStyle(color: AppColors.errorDark),
),
),
),
if (_nicknameCleared)
const Text(
'保存后将清除昵称,展示名回退为用户名',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
const SizedBox(height: 24),
PrimaryButton(
label: '保存',
isLoading: _saving,
//
onPressed: _hasChanges ? _save : null,
),
],
),
);
}
}
/// URL [RemoteImage] key
/// URL
class _AvatarPreview extends StatelessWidget {
const _AvatarPreview({
required this.url,
required this.pendingAssetId,
this.onTap,
});
final String? url;
final String? pendingAssetId;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final Widget content;
if (url != null) {
content = RemoteImage(
url: url!,
width: 96,
height: 96,
borderRadius: BorderRadius.circular(48),
);
} else if (pendingAssetId != null) {
content = const ColoredBox(
color: AppColors.surfaceTint,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle, color: AppColors.primary, size: 24),
SizedBox(height: 4),
Text(
'已选择\n新头像',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.primaryDark, fontSize: 10),
),
],
),
),
);
} else {
content = const ColoredBox(
color: AppColors.surfaceTint,
child: Icon(Icons.person_outline, color: AppColors.muted, size: 40),
);
}
return Semantics(
label: '更换头像',
button: onTap != null,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(width: 96, height: 96, child: ClipOval(child: content)),
),
);
}
}
+31 -243
View File
@@ -1,49 +1,17 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/features/profile/profile_display.dart';
import 'package:patbond_flutter/features/profile/profile_edit_page.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
/// TabT3.5-08
///
/// / /
/// `GET /me`+ `GET /me/community-stats` / +
/// `GET /users/{me}/follow-stats` / M3
/// /24 / 1.8k / 2退
///
/// loading/ error + / ready****
/// `/me/community-stats` 404
/// 0 ready 0
/// [ProfileStatsPhase]
class ProfilePage extends StatefulWidget {
const ProfilePage({
required this.appState,
required this.controller,
super.key,
this.avatarUploaderBuilder,
this.onLogout,
});
class ProfilePage extends StatelessWidget {
const ProfilePage({required this.appState, super.key, this.onLogout});
final AppState appState;
/// Tab `app.dart`
final ProfileController controller;
///
final AvatarUploaderBuilder? avatarUploaderBuilder;
/// 退线
final Future<void> Function()? onLogout;
/// demo ADR-022
/// M5
/// 稿`/me/bookmarks``/me/posts`
/// 05
static const menuItems = [
(Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'),
(Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'),
@@ -52,63 +20,18 @@ class ProfilePage extends StatefulWidget {
(Icons.settings_outlined, '设置与关于', '隐私设置与版本信息'),
];
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
ProfileController get _controller => widget.controller;
@override
void initState() {
super.initState();
// IndexedStack Tab 沿 pets/home
// reset initial
//
// ****`refresh()` notify loading
// initState
// initState Flutterbuild setState
// pets/home initState
if (_controller.phase == ProfileLoadPhase.initial) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _controller.phase == ProfileLoadPhase.initial) {
_controller.refresh();
}
});
}
}
void showDemoMessage(BuildContext context, String name) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('$name」功能为演示入口')));
}
Future<void> _openEdit() async {
if (_controller.profile == null) return;
final saved = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => ProfileEditPage(
controller: _controller,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
),
),
);
if (saved == true && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('资料已更新')));
}
}
/// demo AppState /
///
Future<void> reset(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('恢复演示数据'),
content: const Text('首页天气与本地服务的演示内容会恢复为初始状态(不影响账号资料与宠物档案)'),
content: const Text('宠物资料、疫苗记录以及新增帖子都会恢复为初始状态'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@@ -122,7 +45,7 @@ class _ProfilePageState extends State<ProfilePage> {
),
);
if (confirmed == true) {
await widget.appState.resetDemoData();
await appState.resetDemoData();
if (context.mounted) {
ScaffoldMessenger.of(
context,
@@ -133,43 +56,6 @@ class _ProfilePageState extends State<ProfilePage> {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
switch (_controller.phase) {
case ProfileLoadPhase.initial:
case ProfileLoadPhase.loading:
return const Center(child: CircularProgressIndicator());
case ProfileLoadPhase.error:
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(
message: profileLoadErrorMessage(_controller.lastError),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _controller.refresh,
child: const Text('重试'),
),
],
),
),
);
case ProfileLoadPhase.ready:
return RefreshIndicator(
onRefresh: _controller.refresh,
child: _content(_controller.profile!),
);
}
},
);
}
Widget _content(UserProfile profile) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
children: [
@@ -181,38 +67,40 @@ class _ProfilePageState extends State<ProfilePage> {
),
child: Column(
children: [
_ProfileAvatar(url: profile.avatarUrl, onTap: _openEdit),
RemoteImage(
url: userAvatar,
width: 82,
height: 82,
borderRadius: BorderRadius.circular(41),
),
const SizedBox(height: 12),
Text(
// 退`nickname ?? username` /me
// DB 退 UserProfile.displayName
profile.displayName,
style: const TextStyle(
const Text(
'萌宠新手(豆豆家长)',
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 5),
Text(
'@${profile.username}',
style: const TextStyle(color: AppColors.accent, fontSize: 12),
const Text(
'Patbond 社区创作达人',
style: TextStyle(color: AppColors.accent, fontSize: 12),
),
const SizedBox(height: 12),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white38),
visualDensity: VisualDensity.compact,
),
onPressed: _openEdit,
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('编辑资料'),
),
const SizedBox(height: 16),
const SizedBox(height: 20),
const Divider(color: Colors.white24),
const SizedBox(height: 10),
_statsRow(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const _ProfileStat(value: '24', label: '关注我'),
const _ProfileStat(value: '1.8k', label: '获赞'),
_ProfileStat(
value: '${appState.posts.length}',
label: '我的作品',
),
],
),
],
),
),
@@ -220,7 +108,7 @@ class _ProfilePageState extends State<ProfilePage> {
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
children: ProfilePage.menuItems.map((item) {
children: menuItems.map((item) {
return ListTile(
leading: CircleAvatar(
backgroundColor: AppColors.surfaceTint,
@@ -248,112 +136,12 @@ class _ProfilePageState extends State<ProfilePage> {
),
const SizedBox(height: 10),
TextButton(
onPressed: widget.onLogout ?? () => showDemoMessage(context, '退出登录'),
onPressed: onLogout ?? () => showDemoMessage(context, '退出登录'),
child: const Text('切换账号或退出登录'),
),
],
);
}
/// loading + / error + / ready
///
/// ** 1.8k ** likeCount
///
Widget _statsRow() {
switch (_controller.statsPhase) {
case ProfileStatsPhase.loading:
return const SizedBox(
height: 46,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
case ProfileStatsPhase.error:
return SizedBox(
height: 46,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'统计加载失败',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
TextButton(
onPressed: _controller.refreshStats,
child: const Text(
'重试',
style: TextStyle(color: AppColors.accent),
),
),
],
),
);
case ProfileStatsPhase.ready:
final follow = _controller.followStats;
final community = _controller.communityStats;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_ProfileStat(value: '${follow?.followerCount ?? 0}', label: '关注我'),
_ProfileStat(value: '${follow?.followingCount ?? 0}', label: '我关注'),
_ProfileStat(
value: '${community?.receivedLikeCount ?? 0}',
label: '获赞',
),
_ProfileStat(
value: '${community?.publishedPostCount ?? 0}',
label: '我的作品',
),
],
);
}
}
}
/// URL [RemoteImage] key URL
///
class _ProfileAvatar extends StatelessWidget {
const _ProfileAvatar({required this.url, required this.onTap});
final String? url;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
label: '编辑资料',
button: true,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(
width: 82,
height: 82,
child: ClipOval(
child: url == null
? const ColoredBox(
color: AppColors.surfaceTint,
child: Icon(
Icons.person_outline,
color: AppColors.muted,
size: 36,
),
)
: RemoteImage(
url: url!,
width: 82,
height: 82,
borderRadius: BorderRadius.circular(41),
),
),
),
),
);
}
}
class _ProfileStat extends StatelessWidget {
+79
View File
@@ -134,6 +134,85 @@ 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 {
const CommentModel({
required this.id,
+53 -7
View File
@@ -7,16 +7,13 @@ import 'package:shared_preferences/shared_preferences.dart';
class AppState extends ChangeNotifier {
static const _petKey = 'patbond_pet';
static const _vaccinesKey = 'patbond_vaccines';
static const _postsKey = 'patbond_posts';
static const _locationWeatherKey = 'patbond_location_weather';
/// / / demo T2-12
/// Tab pets feature demo
///
/// **demo T3-17 退**Feed / T3-14/15
/// community T3-17 + 稿/
/// `posts` / `publishPost` / `updatePost`
/// shared_preferences 03 §1.1
PetProfile pet = initialPet;
VaccineRecord vaccines = initialVaccines;
List<PostModel> posts = List<PostModel>.from(initialPosts);
LocationWeather locationWeather = initialLocationWeather;
bool isReady = false;
@@ -24,11 +21,23 @@ class AppState extends ChangeNotifier {
try {
final preferences = await SharedPreferences.getInstance();
final savedPet = preferences.getString(_petKey);
final savedVaccines = preferences.getString(_vaccinesKey);
final savedPosts = preferences.getString(_postsKey);
final savedLocationWeather = preferences.getString(_locationWeatherKey);
if (savedPet != null) {
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) {
locationWeather = LocationWeather.fromJson(
jsonDecode(savedLocationWeather) as Map<String, dynamic>,
@@ -37,6 +46,8 @@ class AppState extends ChangeNotifier {
} catch (error, stackTrace) {
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
pet = initialPet;
vaccines = initialVaccines;
posts = List<PostModel>.from(initialPosts);
locationWeather = initialLocationWeather;
} finally {
isReady = true;
@@ -44,6 +55,33 @@ 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 {
locationWeather = value;
notifyListeners();
@@ -52,15 +90,23 @@ class AppState extends ChangeNotifier {
Future<void> resetDemoData() async {
pet = initialPet;
vaccines = initialVaccines;
posts = List<PostModel>.from(initialPosts);
locationWeather = initialLocationWeather;
notifyListeners();
final preferences = await SharedPreferences.getInstance();
await Future.wait([
preferences.remove(_petKey),
preferences.remove(_vaccinesKey),
preferences.remove(_postsKey),
preferences.remove(_locationWeatherKey),
]);
}
Future<void> _savePosts() {
return _save(_postsKey, posts.map((post) => post.toJson()).toList());
}
Future<void> _save(String key, Object value) async {
try {
final preferences = await SharedPreferences.getInstance();
+4 -34
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/network/signed_network_image.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
class RemoteImage extends StatelessWidget {
@@ -22,10 +21,8 @@ class RemoteImage extends StatelessWidget {
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: borderRadius,
// key T3-14 URL
// URL
child: Image(
image: SignedNetworkImage(url),
child: Image.network(
url,
width: width,
height: height,
fit: fit,
@@ -76,41 +73,14 @@ class SectionCard extends StatelessWidget {
}
}
/// [color] 8% `withAlpha(20)`
///
/// DEBT-1 05 §5.3 [color]
/// 1.672.55:1 WCAG AA
/// primaryprimaryDark 8.74:1successsuccessInk 7.39:1
/// accentaccentDark 7.07:1errorerrorDark 5.78:1ink
/// [inkColor]
class TagPill extends StatelessWidget {
const TagPill(
this.label, {
super.key,
this.color = AppColors.primary,
this.inkColor,
});
const TagPill(this.label, {super.key, this.color = AppColors.primary});
final String label;
/// 8%
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
Widget build(BuildContext context) {
final effectiveInk = inkColor ?? _defaultInkFor(color);
return DecoratedBox(
decoration: BoxDecoration(
color: color.withAlpha(20),
@@ -121,7 +91,7 @@ class TagPill extends StatelessWidget {
child: Text(
label,
style: TextStyle(
color: effectiveInk,
color: color,
fontSize: 11,
fontWeight: FontWeight.w700,
),
@@ -6,13 +6,9 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
-1
View File
@@ -3,7 +3,6 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
)
@@ -5,15 +5,11 @@
import FlutterMacOS
import Foundation
import file_selector_macos
import flutter_image_compress_macos
import flutter_secure_storage_darwin
import package_info_plus
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+1 -213
View File
@@ -57,14 +57,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.dev"
source: hosted
version: "0.3.5+5"
crypto:
dependency: transitive
description:
@@ -98,7 +90,7 @@ packages:
source: hosted
version: "2.2.2"
fake_async:
dependency: "direct dev"
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
@@ -129,38 +121,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
url: "https://pub.dev"
source: hosted
version: "0.9.4+1"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
url: "https://pub.dev"
source: hosted
version: "0.9.5+1"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
url: "https://pub.dev"
source: hosted
version: "0.9.3+6"
fixnum:
dependency: transitive
description:
@@ -174,59 +134,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_image_compress:
dependency: "direct main"
description:
name: flutter_image_compress
sha256: "98a48c05a7add6869c6838270e862124a4d571f9dd5d0cf209ed03a71b20ea84"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
flutter_image_compress_common:
dependency: transitive
description:
name: flutter_image_compress_common
sha256: "76869e4d5f3d65f3431e7edff0b2d8ad1eea68b49c6a37772fdb1ae6016da9ed"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter_image_compress_macos:
dependency: transitive
description:
name: flutter_image_compress_macos
sha256: "0d2a842d2e544828fb32bda16dcfccb3149107df568898a4936d78232e486847"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_image_compress_ohos:
dependency: transitive
description:
name: flutter_image_compress_ohos
sha256: "1491bb7bcfdf59e3b127c263c116cfbf8ed3afade6e7fa691d9622e838ed7e48"
url: "https://pub.dev"
source: hosted
version: "0.0.3+1"
flutter_image_compress_platform_interface:
dependency: transitive
description:
name: flutter_image_compress_platform_interface
sha256: bbefb7967bda565004fdabdb3300dcbfb40c7ef8402675d23206e5bddc358178
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_image_compress_web:
dependency: transitive
description:
name: flutter_image_compress_web
sha256: "91cc58e091a1e09c7683d216d579e2b964119a8527fc43b64dfdb00f1acc94ec"
url: "https://pub.dev"
source: hosted
version: "0.1.5+1"
flutter_lints:
dependency: "direct dev"
description:
@@ -235,19 +142,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.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:
@@ -306,11 +200,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
hooks:
dependency: transitive
description:
@@ -335,83 +224,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
url: "https://pub.dev"
source: hosted
version: "1.2.3"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "1c0c38790306fda4ed774095620444333e56a2b6bc8fc98f3a35c9398781cf54"
url: "https://pub.dev"
source: hosted
version: "0.8.13+22"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae
url: "https://pub.dev"
source: hosted
version: "0.8.13+7"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
jni:
dependency: transitive
description:
@@ -612,14 +424,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.dev"
source: hosted
version: "5.0.6"
pub_semver:
dependency: transitive
description:
@@ -729,14 +533,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
term_glyph:
dependency: transitive
description:
@@ -793,14 +589,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
win32:
dependency: transitive
description:
-16
View File
@@ -39,26 +39,10 @@ dependencies:
flutter_secure_storage: ^11.0.0
uuid: ^4.6.0
package_info_plus: ^10.2.1
# T3-13 媒体上传:系统选择器多选(03 号评估 §4.1 选型)。
image_picker: ^1.2.0
# T3-13 媒体上传:原生编解码压缩(长边重采样 + JPEG 质量 + EXIF 方向矫正)。
flutter_image_compress: ^2.4.0
# M3.5-01 中文本地化:Material/Cupertino/Widgets 内置组件文案(日期选择器
# 标题、确定/取消、输入模式提示与格式错误)由 Global*Localizations 提供。
flutter_localizations:
sdk: flutter
# flutter_localizations 的日期符号/数字格式底座;显式直接依赖以锁定与 SDK
# 一致的版本(避免间接依赖升级悄悄改变格式化行为)。
intl: ^0.20.2
dev_dependencies:
flutter_test:
sdk: flutter
# T3-14 compose 真链路桌面实测入口(integration_test/,环境变量门控)。
integration_test:
sdk: flutter
# 定时冲刷/退避测试的假时钟驱动(flutter_test 传递依赖显式声明)。
fake_async: ^1.3.3
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
-104
View File
@@ -1,104 +0,0 @@
#!/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
-6
View File
@@ -1,6 +0,0 @@
#!/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
@@ -1,159 +0,0 @@
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);
});
});
}
@@ -1,209 +0,0 @@
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/analytics/analytics_service.dart';
/// 退M3 T3-1913 §3.4 4 +
/// iteration-3 06 §2.3fakeAsync 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=120st=90s
elapse(const Duration(seconds: 30));
expect(service.uploadAttempts, 2);
elapse(const Duration(seconds: 30));
expect(service.uploadAttempts, 2);
// t=120s 退 120st=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=480s240s×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();
});
});
});
}
@@ -1,208 +0,0 @@
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;
// v4restore
final serviceB = buildService('http://unused', AnalyticsEventStore());
expect(serviceB.anonymousId, isNot(firstLaunchId));
await serviceB.restore();
expect(serviceB.anonymousId, firstLaunchId);
await serviceB.trackEvent('auth_login_succeeded');
expect(serviceB.pendingEvents.single['anonymousId'], firstLaunchId);
});
test('构造注入 anonymousId 的测试通道不被持久化覆盖', () async {
SharedPreferences.setMockInitialValues({
AnalyticsService.anonymousIdKey: 'anon-stored',
});
final service = AnalyticsService(
apiBaseUrl: 'http://unused',
getAccessToken: null,
getSessionId: () => 'session-x',
anonymousId: 'anon-injected',
store: AnalyticsEventStore(),
);
await service.restore();
expect(service.anonymousId, 'anon-injected');
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString(AnalyticsService.anonymousIdKey), 'anon-stored');
});
});
}
@@ -122,18 +122,5 @@ void main() {
// 20 M0
expect(service.pendingEvents.length, 20);
});
test('持久化不可用时 restore 降级临时 anonymousId 不崩溃', () async {
// setMockInitialValuesSharedPreferences
// 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);
});
});
}
-27
View File
@@ -73,32 +73,5 @@ void main() {
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(tracker.sessionId, isNot(original));
});
test('前后台回调成对触发一次,生命周期级联不重复', () {
var leaveCount = 0;
var enterCount = 0;
final tracker = SessionTracker(
onLeaveForeground: () => leaveCount++,
onEnterForeground: () => enterCount++,
);
// 退inactive hidden paused leave
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
expect(leaveCount, 1);
expect(enterCount, 0);
// hidden inactive resumed enter
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(leaveCount, 1);
expect(enterCount, 1);
// resumed resumed enter
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(enterCount, 1);
});
});
}
-49
View File
@@ -1,49 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/app/app.dart';
import 'package:patbond_flutter/app/app_localization.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/auth_test_helpers.dart';
import '../helpers/community_test_helpers.dart';
import '../helpers/pet_test_helpers.dart';
/// M3.5-01 [MaterialApp] zh-CN
///
/// delegate
/// Material 退
void main() {
testWidgets('MaterialApp 挂三件套 delegate + 单语言 zh-CN', (tester) async {
SharedPreferences.setMockInitialValues({});
final session = SessionManager(store: InMemoryTokenStore())
..markAuthenticated();
await tester.pumpWidget(
App(
sessionManager: session,
authRepository: FakeAuthRepository(),
petsRepository: FakePetsRepository(),
communityRepository: FakeCommunityRepository()
..onFeed = (_, _) async => feedPage(const []),
),
);
await tester.pumpAndSettle();
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
expect(app.localizationsDelegates, appLocalizationsDelegates);
expect(app.supportedLocales, appSupportedLocales);
expect(app.locale, const Locale('zh', 'CN'));
// Material Default*
final context = tester.element(find.byType(NavigationBar));
final l10n = MaterialLocalizations.of(context);
expect(l10n.okButtonLabel, '确定');
expect(l10n.cancelButtonLabel, '取消');
expect(l10n.datePickerHelpText, '选择日期');
expect(l10n.dateInputLabel, '输入日期');
expect(l10n.inputDateModeButtonLabel, '切换到输入模式');
expect(l10n.invalidDateFormatLabel, '格式无效。');
expect(l10n.formatMonthYear(DateTime(2026, 9, 10)), '2026年9月');
});
}
-71
View File
@@ -1,71 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/models/patch_field.dart';
/// PATCH JSON v1.4.0 `UpdateMeRequest` /
/// `UpdatePetRequest.avatarAssetId`
///
/// 线**absent ** absent `null`
/// null
void main() {
test('absent 不落键 / clear 落 null / value 落值', () {
final json = <String, Object?>{};
const PatchField<String>.absent().writeTo(json, 'a');
expect(json.containsKey('a'), isFalse, reason: 'absent 必须连键都不出现');
const PatchField<String>.clear().writeTo(json, 'b');
expect(json.containsKey('b'), isTrue);
expect(json['b'], isNull);
const PatchField<String>.value('x').writeTo(json, 'c');
expect(json['c'], 'x');
});
test('absent 与 clear 的 valueOrNull 同为 null,只能靠 isPresent 区分', () {
const absent = PatchField<String>.absent();
const clear = PatchField<String>.clear();
expect(absent.valueOrNull, isNull);
expect(clear.valueOrNull, isNull);
expect(absent.isPresent, isFalse);
expect(clear.isPresent, isTrue);
expect(absent.isClear, isFalse);
expect(clear.isClear, isTrue);
expect(const PatchField<String>.value('x').isClear, isFalse);
});
test('encode 只作用于有值态(clear 不调 encode,避免 null 解引用)', () {
final json = <String, Object?>{};
var encodeCalls = 0;
const PatchField<int>.clear().writeTo(
json,
'n',
encode: (value) {
encodeCalls += 1;
return value.toString();
},
);
expect(json['n'], isNull);
expect(encodeCalls, 0);
const PatchField<int>.value(7).writeTo(
json,
'm',
encode: (value) {
encodeCalls += 1;
return value.toString();
},
);
expect(json['m'], '7');
expect(encodeCalls, 1);
});
test('值相等即相等(absent 与 clear 不相等)', () {
expect(
const PatchField<String>.value('a'),
const PatchField<String>.value('a'),
);
expect(
const PatchField<String>.absent(),
isNot(const PatchField<String>.clear()),
);
});
}
@@ -1,70 +0,0 @@
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));
});
});
}
-385
View File
@@ -1,385 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/app/app_localization.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/app_date_picker.dart';
/// M3.5-01/02
///
///
void main() {
/// app + zh-CN delegate宿 delegate
/// Material 退
Widget host(Widget child) => MaterialApp(
theme: buildAppTheme(),
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
home: Scaffold(body: child),
);
group('dateOnly / today / isDateSelectable', () {
test('dateOnly 抹掉时分秒', () {
expect(
dateOnly(DateTime(2026, 4, 9, 23, 59, 58, 777)),
DateTime(2026, 4, 9),
);
});
test('today 为本地当天零点', () {
final now = DateTime.now();
expect(today(), DateTime(now.year, now.month, now.day));
});
test('isDateSelectable 闭区间按日粒度判定', () {
final first = DateTime(2026, 9, 1);
final last = DateTime(2026, 9, 30, 8, 30);
expect(
isDateSelectable(
date: DateTime(2026, 9, 1),
firstDate: first,
lastDate: last,
),
isTrue,
);
// lastDate
expect(
isDateSelectable(
date: DateTime(2026, 9, 30, 23, 0),
firstDate: first,
lastDate: last,
),
isTrue,
);
expect(
isDateSelectable(
date: DateTime(2026, 8, 31),
firstDate: first,
lastDate: last,
),
isFalse,
);
expect(
isDateSelectable(
date: DateTime(2026, 10, 1),
firstDate: first,
lastDate: last,
),
isFalse,
);
});
});
group('pickAppDate · 中文本地化实际渲染', () {
testWidgets('日历模式:标题/确定/取消全中文(此前为 Select date / OK / Cancel', (
tester,
) async {
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () => pickAppDate(
context: context,
initialDate: DateTime(2026, 9, 10),
firstDate: DateTime(1990),
lastDate: DateTime(2026, 9, 30),
),
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
expect(find.text('选择日期'), findsOneWidget);
expect(find.text('确定'), findsOneWidget);
expect(find.text('取消'), findsOneWidget);
expect(find.text('Select date'), findsNothing);
expect(find.text('OK'), findsNothing);
expect(find.text('Cancel'), findsNothing);
});
testWidgets('保留手输切换按钮(不用 calendarOnly),切换后提示与标签为中文', (tester) async {
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () => pickAppDate(
context: context,
initialDate: DateTime(2026, 9, 10),
firstDate: DateTime(1990),
lastDate: DateTime(2026, 9, 30),
),
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
// calendarOnly
//
final toggle = find.byIcon(Icons.edit_outlined);
expect(toggle, findsOneWidget);
await tester.tap(toggle);
await tester.pumpAndSettle();
expect(find.text('输入日期'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
final field = tester.widget<TextField>(find.byType(TextField));
// / zh-CN mm/dd/yyyy
expect(field.decoration!.labelText, isNot(contains('Date')));
expect(field.decoration!.hintText, isNot('mm/dd/yyyy'));
});
testWidgets('手输模式敲入已知日期即可返回(无需逐月点箭头)', (tester) async {
DateTime? picked;
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () async {
picked = await pickAppDate(
context: context,
initialDate: DateTime(2026, 9, 10),
firstDate: DateTime(1990),
lastDate: DateTime(2026, 9, 30),
);
},
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.edit_outlined));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '2026/04/09');
await tester.pumpAndSettle();
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
expect(picked, DateTime(2026, 4, 9));
});
testWidgets('取消返回 null;确认返回值已抹掉时分秒', (tester) async {
DateTime? picked;
var calls = 0;
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () async {
calls++;
picked = await pickAppDate(
context: context,
initialDate: DateTime(2026, 9, 10, 15, 30),
firstDate: DateTime(1990),
lastDate: DateTime(2026, 9, 30),
);
},
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
await tester.tap(find.text('取消'));
await tester.pumpAndSettle();
expect(calls, 1);
expect(picked, isNull);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
expect(picked, DateTime(2026, 9, 10));
});
testWidgets('initialDate 越界自动夹进 [firstDate, lastDate](不触发原生断言)', (
tester,
) async {
DateTime? picked;
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () async {
// firstDatefirstDate
//
picked = await pickAppDate(
context: context,
initialDate: DateTime(2020, 1, 1),
firstDate: DateTime(2026, 9, 1),
lastDate: DateTime(2026, 9, 30),
);
},
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
expect(picked, DateTime(2026, 9, 1));
});
});
group('AppDateFieldTrailing · 「今天」快捷键', () {
testWidgets('点「今天」直接回调今天,不开弹窗', (tester) async {
DateTime? got;
await tester.pumpWidget(
host(
AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime(2100),
onToday: (value) => got = value,
),
),
);
expect(find.text('今天'), findsOneWidget);
expect(find.byIcon(Icons.calendar_month_outlined), findsOneWidget);
await tester.tap(find.text('今天'));
await tester.pumpAndSettle();
expect(got, today());
//
expect(find.text('选择日期'), findsNothing);
});
testWidgets('今天越界时隐藏按钮,只留日历图标', (tester) async {
await tester.pumpWidget(
host(
AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime(1999, 12, 31),
onToday: (_) {},
),
),
);
expect(find.text('今天'), findsNothing);
expect(find.byIcon(Icons.calendar_month_outlined), findsOneWidget);
});
testWidgets('enabled=false(提交中)按钮禁用', (tester) async {
var calls = 0;
await tester.pumpWidget(
host(
AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime(2100),
enabled: false,
onToday: (_) => calls++,
),
),
);
expect(
tester.widget<TextButton>(find.byType(TextButton)).onPressed,
isNull,
);
await tester.tap(find.text('今天'), warnIfMissed: false);
await tester.pumpAndSettle();
expect(calls, 0);
});
testWidgets('触控目标不小于 44×44', (tester) async {
await tester.pumpWidget(
host(
AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime(2100),
onToday: (_) {},
),
),
);
final size = tester.getSize(find.byType(TextButton));
expect(size.width, greaterThanOrEqualTo(44));
expect(size.height, greaterThanOrEqualTo(44));
});
});
group('datePickerTheme · 品牌配色(不再是 fromSeed 的暗红棕)', () {
test('选中日/选中年为 primaryStrong 实底白字,今日 primaryStrong 描边', () {
final theme = buildAppTheme().datePickerTheme;
const selected = <WidgetState>{WidgetState.selected};
expect(
theme.dayBackgroundColor!.resolve(selected),
AppColors.primaryStrong,
);
expect(theme.dayForegroundColor!.resolve(selected), Colors.white);
expect(
theme.yearBackgroundColor!.resolve(selected),
AppColors.primaryStrong,
);
expect(theme.yearForegroundColor!.resolve(selected), Colors.white);
expect(theme.todayBorder!.color, AppColors.primaryStrong);
expect(
theme.todayForegroundColor!.resolve(const {}),
AppColors.primaryStrong,
);
// 沿 chipsurfaceTint + primaryDark 7.98:1
expect(theme.headerBackgroundColor, AppColors.surfaceTint);
expect(theme.headerForegroundColor, AppColors.primaryDark);
expect(theme.backgroundColor, AppColors.surface);
// mutedDEBT-2 muted
expect(
theme.dayForegroundColor!.resolve(const {WidgetState.disabled}),
AppColors.muted,
);
});
testWidgets('实际渲染:选中日圆底取 primaryStrong', (tester) async {
await tester.pumpWidget(
host(
Builder(
builder: (context) => TextButton(
onPressed: () => pickAppDate(
context: context,
initialDate: DateTime(2026, 9, 10),
firstDate: DateTime(1990),
lastDate: DateTime(2026, 9, 30),
),
child: const Text('打开'),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
// Ink(decoration: ShapeDecoration)
// datePickerTheme.dayBackgroundColor / todayBackgroundColor
final inkColors = tester
.widgetList<Ink>(find.byType(Ink))
.map((ink) => ink.decoration)
.whereType<ShapeDecoration>()
.map((d) => d.color)
.toList();
expect(
inkColors,
contains(AppColors.primaryStrong),
reason: '选中日未使用品牌 primaryStrong 实底(仍是 fromSeed 派生色)',
);
});
});
}
@@ -1,162 +0,0 @@
import 'dart:async';
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/avatar_upload_sheet.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import '../../helpers/community_test_helpers.dart';
import '../../helpers/media_test_helpers.dart';
/// sheet MediaUploader
///
void main() {
late FakeCommunityRepository community;
late FakeMediaImagePicker picker;
late FakeMediaCompressor compressor;
late FakeDirectUploadClient direct;
setUp(() {
community = FakeCommunityRepository();
picker = FakeMediaImagePicker([decodablePickedImage()]);
compressor = FakeMediaCompressor();
direct = FakeDirectUploadClient();
community.onCreateMediaUpload = (_) async => credentials();
community.onCompleteMediaUpload = (assetId) async =>
readyAsset(assetId: assetId);
});
MediaUploader build(MediaPurpose purpose) => MediaUploader(
repository: community,
purpose: purpose,
picker: picker,
compressor: compressor,
directUpload: direct,
maxImages: 1,
maxConcurrentUploads: 1,
);
/// sheet pop
Future<List<String?>> pumpSheet(
WidgetTester tester, {
MediaPurpose purpose = MediaPurpose.userAvatar,
}) async {
final results = <String?>[];
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () async {
results.add(
await showAvatarUploadSheet(
context,
builder: build,
purpose: purpose,
),
);
},
child: const Text('打开'),
),
),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pump();
return results;
}
testWidgets('打开即自动拉起选择器(无需多点一次「选择图片」)', (tester) async {
picker.gate = Completer<void>();
await pumpSheet(tester);
await tester.pump();
expect(find.text('正在打开相册…'), findsOneWidget);
expect(picker.limits, [1], reason: 'maxImages=1 → 只取一张');
picker.gate!.complete();
await tester.pumpAndSettle();
});
testWidgets('上传中:线性进度 + 百分比;ready 后给预览与「使用这张」', (tester) async {
direct.manual = true;
final results = await pumpSheet(tester);
await tester.pumpAndSettle();
expect(find.byType(LinearProgressIndicator), findsOneWidget);
final call = direct.calls.single;
call.emitProgress(30, 100);
await tester.pump();
expect(find.text('上传中 30%'), findsOneWidget);
call.succeed();
await tester.pumpAndSettle();
expect(find.text('使用这张'), findsOneWidget);
expect(find.byType(Image), findsOneWidget, reason: 'ready 态给整幅预览');
await tester.tap(find.text('使用这张'));
await tester.pumpAndSettle();
expect(results.single, 'a-1', reason: '只交付 ready 态的 assetId(孤儿防护)');
});
testWidgets('purpose 透传到 createUpload(宠物头像 = pet_avatar', (tester) async {
await pumpSheet(tester, purpose: MediaPurpose.petAvatar);
await tester.pumpAndSettle();
expect(community.lastMediaUploadRequest!.purpose, MediaPurpose.petAvatar);
expect(community.lastMediaUploadRequest!.purpose.wire, 'pet_avatar');
});
testWidgets('可重试失败:给「重试」+「重新选择」,重试成功后可确认', (tester) async {
direct.scriptedOutcomes.add(
const MediaDirectUploadException(message: '断网'),
);
final results = await pumpSheet(tester);
await tester.pumpAndSettle();
expect(find.text('网络中断,上传失败'), findsOneWidget);
expect(find.text('重试'), findsOneWidget);
expect(find.text('重新选择'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('使用这张'), findsOneWidget);
await tester.tap(find.text('使用这张'));
await tester.pumpAndSettle();
expect(results.single, 'a-1');
});
testWidgets('不可重试失败(压缩后仍超限):只给「重新选择」,不给「重试」', (tester) async {
compressor.sizePerQuality = {80: 20 * 1024 * 1024, 60: 15 * 1024 * 1024};
await pumpSheet(tester);
await tester.pumpAndSettle();
expect(find.text('图片过大,压缩后仍超过 10 MB'), findsOneWidget);
expect(find.text('重试'), findsNothing, reason: '重试同一张必然再失败,给重试钮是误导');
expect(find.text('重新选择'), findsOneWidget);
});
testWidgets('用户在系统选择器里取消 → 空态可再次选择,pop 结果为 null', (tester) async {
picker = FakeMediaImagePicker(const []);
final results = await pumpSheet(tester);
await tester.pumpAndSettle();
expect(find.text('还没有选择图片'), findsOneWidget);
expect(find.text('选择图片'), findsOneWidget);
// sheet ready assetId
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(results.single, isNull);
});
}
-75
View File
@@ -1,75 +0,0 @@
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);
});
}
@@ -1,45 +0,0 @@
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);
});
}
-155
View File
@@ -1,155 +0,0 @@
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);
});
}

Some files were not shown because too many files have changed in this diff Show More