diff --git a/lib/features/pets/health_record_analytics.dart b/lib/features/pets/health_record_analytics.dart new file mode 100644 index 0000000..4bda5c9 --- /dev/null +++ b/lib/features/pets/health_record_analytics.dart @@ -0,0 +1,125 @@ +import 'package:patbond_flutter/analytics/page_view_tracker.dart'; + +/// health_record 域埋点强类型封装(06 号规划 §1.4 字典 v2;后端白名单 +/// 已扩充就绪,24 号报告 §2.2)。沿用 13 号规范 §3.1 惯例:枚举编译期 +/// 锁死,业务代码禁止手拼事件名与属性。 +/// +/// T2-13 挂接创建漏斗三事件 + viewed;edit/deleted 事件的挂接随 +/// 编辑/删除交互落地(「标记完成」等)另行接线,见 25 号报告遗留。 + +/// 记录类型(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 验收新增三值)。与 pet 域同款 +/// 网络归并口径:断网/超时/5xx 均并入 network_error,server_error +/// 保留给无法归类的兜底。 +enum HealthRecordFailureReason { + validationError('validation_error'), + permissionDenied('permission_denied'), + notFound('not_found'), + rateLimited('rate_limited'), + networkError('network_error'), + serverError('server_error'); + + const HealthRecordFailureReason(this.value); + + final String value; +} + +/// viewed 的来源(06 §1.4 source 枚举)。 +enum HealthRecordViewSource { + recordList('record_list'), + petDetail('pet_detail'), + reminder('reminder'); + + const HealthRecordViewSource(this.value); + + final String value; +} + +class HealthRecordAnalytics { + HealthRecordAnalytics(this._track); + + /// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。 + final TrackEventFn _track; + + /// 进入某类记录的创建表单并产生首次输入(每次进入记一次,表单层去重)。 + void createStarted({ + required HealthRecordType recordType, + required HealthRecordEntryPoint entryPoint, + }) { + _track('health_record_create_started', { + 'recordType': recordType.value, + 'entryPoint': entryPoint.value, + }); + } + + /// 创建接口成功响应(漏斗事件,北极星与 H1/H3/H4 的核心数据源)。 + /// + /// [photoCount] 无照片为 0(M2 不做媒体上传,恒 0,字段随字典保留)。 + void createSucceeded({ + required HealthRecordType recordType, + required int durationMs, + int photoCount = 0, + }) { + _track('health_record_create_succeeded', { + 'recordType': recordType.value, + 'durationMs': durationMs, + 'photoCount': photoCount, + }); + } + + /// 创建失败:失败响应 / 超时 / 本地校验拦截。 + /// + /// [errorCode] 为业务错误码(本地校验/网络错误时缺席); + /// [httpStatus] 由五位业务码推导(`code ~/ 100`); + /// [attemptSeq] 为本次表单会话内第几次提交尝试(从 1 起)。 + void createFailed({ + required HealthRecordType recordType, + required HealthRecordFailureReason reason, + required int attemptSeq, + int? errorCode, + }) { + _track('health_record_create_failed', { + 'recordType': recordType.value, + 'failureReason': reason.value, + 'attemptSeq': attemptSeq, + 'errorCode': ?errorCode, + if (errorCode != null && errorCode >= 10000) + 'httpStatus': errorCode ~/ 100, + }); + } + + /// 记录曝光。06 §1.4 定义在详情页可见;M2 体重/疫苗无独立详情页, + /// 以「列表页每次进入首个成功加载」为曝光时点(每次进入一次、 + /// 不随滚动逐条上报,防事件洪水的意图不变)。 + void viewed({ + required HealthRecordType recordType, + required HealthRecordViewSource source, + }) { + _track('health_record_viewed', { + 'recordType': recordType.value, + 'source': source.value, + }); + } +} diff --git a/lib/features/pets/health_record_display.dart b/lib/features/pets/health_record_display.dart new file mode 100644 index 0000000..a183dce --- /dev/null +++ b/lib/features/pets/health_record_display.dart @@ -0,0 +1,86 @@ +/// 体重 / 疫苗展示与输入解析的纯函数集合(列表 / 表单共用,可单测)。 +library; + +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; + +/// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。 +/// 非法(非数字、越界、三位小数)返回 null,由表单层给 errorText。 +double? parseWeightKgInput(String raw) { + final text = raw.trim(); + if (!RegExp(r'^\d{1,3}(\.\d{1,2})?$').hasMatch(text)) return null; + final value = double.parse(text); + if (value <= 0 || value > 500) return null; + return value; +} + +/// 体重展示:去掉无意义尾零(4.35 → 4.35、5.00 → 5、4.50 → 4.5)。 +String formatWeightKg(double weightKg) { + var text = weightKg.toStringAsFixed(2); + if (text.contains('.')) { + text = text.replaceFirst(RegExp(r'0+$'), ''); + text = text.replaceFirst(RegExp(r'\.$'), ''); + } + return text; +} + +String vaccinationStatusLabel(VaccinationStatus status) => switch (status) { + VaccinationStatus.scheduled => '计划中', + VaccinationStatus.completed => '已完成', + VaccinationStatus.cancelled => '已取消', +}; + +/// 状态标签基色(TagPill 淡染底;文字深变体由 TagPill 内置映射)。 +Color vaccinationStatusColor(VaccinationStatus status) => switch (status) { + VaccinationStatus.scheduled => AppColors.accent, + VaccinationStatus.completed => AppColors.success, + VaccinationStatus.cancelled => AppColors.muted, +}; + +/// 剂次展示:doseLabel 优先,缺席回落「第 N 针」。 +String vaccinationDoseLabel(Vaccination vaccination) => + vaccination.doseLabel ?? '第 ${vaccination.doseNo} 针'; + +/// 疫苗条目副行:按状态给日期语义(计划 / 接种 + 下次到期 / 取消)。 +String vaccinationDateLine( + Vaccination vaccination, +) => switch (vaccination.status) { + VaccinationStatus.scheduled => + '计划 ${vaccination.plannedOn == null ? '未定' : dateToJson(vaccination.plannedOn!)}', + VaccinationStatus.completed => + '接种 ${vaccination.administeredOn == null ? '—' : dateToJson(vaccination.administeredOn!)}' + '${vaccination.nextDueOn == null ? '' : ' · 下次 ${dateToJson(vaccination.nextDueOn!)}'}', + VaccinationStatus.cancelled => '已取消', +}; + +/// 摘要疫苗进度展示:契约 null 语义——无登记为 null(不是 0/0), +/// 调用方对 null 自行给空态文案。 +String vaccinationProgressLabel(SummaryVaccinationProgress progress) => + '${progress.completedDoses}/${progress.totalDoses}'; + +/// 疫苗状态-日期规则前端校验(契约 42201 规则的前置拦截;纯函数可单测): +/// scheduled 必有 plannedOn;completed 必有 administeredOn; +/// nextDueOn 与 administeredOn 同时存在时须 nextDueOn ≥ administeredOn。 +/// 通过返回 null,违反返回给用户的拦截文案。 +String? vaccinationDateRuleError({ + required VaccinationStatus status, + DateTime? plannedOn, + DateTime? administeredOn, + DateTime? nextDueOn, +}) { + if (status == VaccinationStatus.scheduled && plannedOn == null) { + return '请选择计划接种日期'; + } + if (status == VaccinationStatus.completed && administeredOn == null) { + return '请选择接种日期'; + } + if (administeredOn != null && + nextDueOn != null && + nextDueOn.isBefore( + DateTime(administeredOn.year, administeredOn.month, administeredOn.day), + )) { + return '下次接种日期不能早于接种日期'; + } + return null; +} diff --git a/lib/features/pets/pets_controller.dart b/lib/features/pets/pets_controller.dart index 0eb5620..2d1c92f 100644 --- a/lib/features/pets/pets_controller.dart +++ b/lib/features/pets/pets_controller.dart @@ -21,6 +21,10 @@ class PetsController extends ChangeNotifier { final PetsRepository _repository; + /// 记录级页面(体重/疫苗/事件/提醒)直接经仓库取数(22 号报告 §7 + /// 交接约定:页面级状态按页自建,不膨胀本控制器)。 + PetsRepository get repository => _repository; + PetsLoadPhase _phase = PetsLoadPhase.initial; List _pets = const []; ApiException? _lastError; diff --git a/lib/features/pets/weight_form_page.dart b/lib/features/pets/weight_form_page.dart new file mode 100644 index 0000000..ee6af84 --- /dev/null +++ b/lib/features/pets/weight_form_page.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_text_field.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/core/widgets/primary_button.dart'; +import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; +import 'package:patbond_flutter/features/pets/health_record_display.dart'; +import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; +import 'package:patbond_flutter/features/pets/pets_repository.dart'; + +/// 体重录入表单页(T2-13)。 +/// +/// - `weightKg` 前端校验对齐契约:(0, 500]、最多两位小数;后端 40000 兜底。 +/// - 称重时间默认「现在」,可回选日期(非今日取当日 12:00,保持 +/// measured_at DESC 排序直觉);提交前转 UTC,ISO 8601 带 Z 上送。 +/// - 错误分层沿用登录纵切:字段 errorText / InlineErrorBanner / SnackBar。 +/// - 埋点:首次输入 health_record_create_started(recordType=weight), +/// 成功/失败按 06 §1.4 经 [HealthRecordAnalytics] 上报。 +class WeightFormPage extends StatefulWidget { + const WeightFormPage({ + required this.repository, + required this.petId, + super.key, + this.analytics, + this.entryPoint = HealthRecordEntryPoint.recordList, + }); + + final PetsRepository repository; + final String petId; + final HealthRecordAnalytics? analytics; + final HealthRecordEntryPoint entryPoint; + + @override + State createState() => _WeightFormPageState(); +} + +class _WeightFormPageState extends State { + 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 _submit() async { + if (_submitting) return; + _attemptSeq++; + final weightError = _validateWeight(); + if (weightError != null) { + setState(() => _weightError = weightError); + _trackFailed(HealthRecordFailureReason.validationError); + return; + } + + setState(() { + _submitting = true; + _formError = null; + }); + try { + final note = _noteCtrl.text.trim(); + final record = await widget.repository.createWeight( + widget.petId, + CreateWeightRequest( + weightKg: parseWeightKgInput(_weightCtrl.text)!, + measuredAt: _measuredAt(), + note: note.isEmpty ? null : note, + ), + ); + widget.analytics?.createSucceeded( + recordType: HealthRecordType.weight, + durationMs: DateTime.now().difference(_openedAt).inMilliseconds, + ); + if (mounted) Navigator.of(context).pop(record); + } on PetAccessDeniedException { + if (!mounted) return; + _showFormError('你没有权限为该宠物添加记录'); + _trackFailed(HealthRecordFailureReason.permissionDenied, 40300); + } on PetNotFoundException { + if (!mounted) return; + final navigator = Navigator.of(context); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除'))); + _trackFailed(HealthRecordFailureReason.notFound, 40401); + navigator.pop(); + } on ApiRateLimitException { + if (!mounted) return; + _showFormError('操作过于频繁,请稍后再试'); + _trackFailed(HealthRecordFailureReason.rateLimited); + } on ApiBusinessException catch (error) { + if (!mounted) return; + final isParam = error.code == ApiCodes.paramError; + _showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试'); + _trackFailed( + isParam + ? HealthRecordFailureReason.validationError + : HealthRecordFailureReason.serverError, + error.code, + ); + } on ApiNetworkException { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('网络异常,请检查网络后重试'), + action: SnackBarAction(label: '重试', onPressed: _submit), + ), + ); + _trackFailed(HealthRecordFailureReason.networkError); + } on SessionExpiredException { + // 会话失效:认证状态机自动回登录页。 + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + foregroundColor: AppColors.ink, + title: const Text('记录体重'), + centerTitle: true, + titleTextStyle: const TextStyle( + color: AppColors.ink, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 30), + children: [ + AppTextField( + label: '体重(公斤)', + controller: _weightCtrl, + prefixIcon: Icons.monitor_weight_outlined, + errorText: _weightError, + helperText: '大于 0 且不超过 500,最多两位小数', + enabled: !_submitting, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + textInputAction: TextInputAction.next, + onChanged: (_) { + _markStarted(); + if (_weightError != null || _formError != null) { + setState(() { + _weightError = null; + _formError = null; + }); + } + }, + ), + const SizedBox(height: 14), + ListTile( + shape: RoundedRectangleBorder( + side: const BorderSide(color: AppColors.border), + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + tileColor: AppColors.surface, + leading: const Icon(Icons.event_outlined, color: AppColors.muted), + title: const Text('称重日期', style: TextStyle(fontSize: 14)), + subtitle: Text( + dateToJson(_measuredDate), + style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), + ), + trailing: const Icon( + Icons.calendar_month_outlined, + color: AppColors.muted, + ), + enabled: !_submitting, + onTap: () async { + final now = DateTime.now(); + final value = await showDatePicker( + context: context, + initialDate: _measuredDate, + firstDate: DateTime(1990), + lastDate: now, + ); + if (value != null && mounted) { + _markStarted(); + setState(() => _measuredDate = value); + } + }, + ), + const SizedBox(height: 14), + AppTextField( + label: '备注(可选,如:饭后称重)', + controller: _noteCtrl, + prefixIcon: Icons.sticky_note_2_outlined, + enabled: !_submitting, + textInputAction: TextInputAction.done, + onChanged: (_) => _markStarted(), + onSubmitted: (_) => _submit(), + ), + if (_formError != null) ...[ + const SizedBox(height: 16), + InlineErrorBanner(message: _formError!), + ], + const SizedBox(height: 24), + PrimaryButton( + label: '保存记录', + isLoading: _submitting, + onPressed: _submit, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/pets/weight_records_page.dart b/lib/features/pets/weight_records_page.dart new file mode 100644 index 0000000..3b51c98 --- /dev/null +++ b/lib/features/pets/weight_records_page.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/analytics/analytics_page_name.dart'; +import 'package:patbond_flutter/core/navigation/fade_route.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/core/widgets/record_type_dot.dart'; +import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; +import 'package:patbond_flutter/features/pets/health_record_display.dart'; +import 'package:patbond_flutter/features/pets/pet_display.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; +import 'package:patbond_flutter/features/pets/pets_repository.dart'; +import 'package:patbond_flutter/features/pets/weight_form_page.dart'; + +enum _ListPhase { loading, ready, error } + +/// 体重历史列表页(T2-13):cursor 分页(「加载更多」追加,末页收起), +/// 四态齐备;新记录经 [WeightFormPage] 录入后就地插入列表头。 +/// +/// 曝光埋点:每次进入首个成功加载上报一次 +/// `health_record_viewed(recordType=weight, source=pet_detail)` +/// (工单口径:列表曝光;不随滚动逐条上报)。 +class WeightRecordsPage extends StatefulWidget { + const WeightRecordsPage({ + required this.repository, + required this.petId, + required this.canWrite, + super.key, + this.analytics, + this.pageSize, + }); + + final PetsRepository repository; + final String petId; + + /// owner/caregiver 可写;viewer 隐藏录入入口(40300 语义前置)。 + final bool canWrite; + + final HealthRecordAnalytics? analytics; + + /// 每页条数(测试注入小页验证分页;缺省走服务端默认 20)。 + final int? pageSize; + + @override + State createState() => _WeightRecordsPageState(); +} + +class _WeightRecordsPageState extends State { + _ListPhase _phase = _ListPhase.loading; + List _records = const []; + String? _nextCursor; + bool _hasMore = false; + bool _loadingMore = false; + ApiException? _error; + bool _viewedFired = false; + + @override + void initState() { + super.initState(); + _loadFirstPage(); + } + + Future _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 _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 _openCreate() async { + final created = await Navigator.of(context).push( + fadePageRoute( + WeightFormPage( + repository: widget.repository, + petId: widget.petId, + analytics: widget.analytics, + ), + // record_form:健康记录漏斗到达段页名(06 §1.6 / §2.2)。 + settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName), + ), + ); + if (created != null && mounted) { + setState(() { + // 契约排序 measured_at DESC, id DESC:新记录通常最新,就地插头; + // 补录历史日期的位置以下次刷新为准(服务端是唯一事实来源)。 + _records = [created, ..._records]; + if (_phase != _ListPhase.ready) _phase = _ListPhase.ready; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('已记录体重'))); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + foregroundColor: AppColors.ink, + title: const Text('体重记录'), + centerTitle: true, + titleTextStyle: const TextStyle( + color: AppColors.ink, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + actions: [ + if (widget.canWrite && _phase == _ListPhase.ready) + IconButton( + tooltip: '记录体重', + onPressed: _openCreate, + icon: const Icon(Icons.add), + ), + ], + ), + body: switch (_phase) { + _ListPhase.loading => const Center(child: CircularProgressIndicator()), + _ListPhase.error => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InlineErrorBanner(message: petLoadErrorMessage(_error)), + const SizedBox(height: 16), + FilledButton( + onPressed: _loadFirstPage, + child: const Text('重试'), + ), + ], + ), + ), + ), + _ListPhase.ready when _records.isEmpty => Center( + child: SingleChildScrollView( + child: EmptyStateIllustration( + icon: Icons.monitor_weight_outlined, + title: '还没有体重记录', + description: '定期称重,看见毛孩子的成长曲线', + ctaLabel: widget.canWrite ? '记录第一条' : null, + onCtaPressed: widget.canWrite ? _openCreate : null, + ), + ), + ), + _ListPhase.ready => _list(), + }, + ); + } + + Widget _list() { + return RefreshIndicator( + onRefresh: _loadFirstPage, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 30), + children: [ + for (final record in _records) ...[ + _WeightTile(record: record), + const SizedBox(height: 10), + ], + if (_hasMore) + Center( + child: _loadingMore + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : TextButton(onPressed: _loadMore, child: const Text('加载更多')), + ), + ], + ), + ); + } +} + +/// 体重条目:RecordTypeDot(体重) + 大数值(primaryDark w800)+ +/// 日期/备注副行(05 §3.3 时间线条目形态的数值型变体)。 +class _WeightTile extends StatelessWidget { + const _WeightTile({required this.record}); + + final WeightRecord record; + + @override + Widget build(BuildContext context) { + final meta = StringBuffer(formatMeasuredAt(record.measuredAt)); + if (record.note != null && record.note!.isNotEmpty) { + meta.write(' · ${record.note}'); + } + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + const RecordTypeDot( + type: RecordType.weight, + size: RecordTypeDotSize.md, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${formatWeightKg(record.weightKg)} kg', + style: const TextStyle( + color: AppColors.primaryDark, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + meta.toString(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.inkSoft, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 称重时刻展示:本地时区 `YYYY-MM-DD HH:mm`。 +String formatMeasuredAt(DateTime measuredAt) { + final local = measuredAt.toLocal(); + final h = local.hour.toString().padLeft(2, '0'); + final min = local.minute.toString().padLeft(2, '0'); + return '${dateToJson(local)} $h:$min'; +} diff --git a/test/features/pets/health_record_analytics_test.dart b/test/features/pets/health_record_analytics_test.dart new file mode 100644 index 0000000..8a90f21 --- /dev/null +++ b/test/features/pets/health_record_analytics_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; + +void main() { + late List<(String, Map?)> events; + late HealthRecordAnalytics analytics; + + setUp(() { + events = []; + analytics = HealthRecordAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + test('createStarted:recordType + entryPoint 两属性', () { + analytics.createStarted( + recordType: HealthRecordType.weight, + entryPoint: HealthRecordEntryPoint.recordList, + ); + + expect(events.single.$1, 'health_record_create_started'); + expect(events.single.$2, { + 'recordType': 'weight', + 'entryPoint': 'record_list', + }); + }); + + test('createSucceeded:durationMs + photoCount(M2 无媒体恒 0)', () { + analytics.createSucceeded( + recordType: HealthRecordType.vaccine, + durationMs: 1234, + ); + + expect(events.single.$1, 'health_record_create_succeeded'); + expect(events.single.$2, { + 'recordType': 'vaccine', + 'durationMs': 1234, + 'photoCount': 0, + }); + }); + + test('createFailed:五位业务码推导 httpStatus(40904 → 409)', () { + analytics.createFailed( + recordType: HealthRecordType.vaccine, + reason: HealthRecordFailureReason.validationError, + attemptSeq: 2, + errorCode: 40904, + ); + + expect(events.single.$2, { + 'recordType': 'vaccine', + 'failureReason': 'validation_error', + 'attemptSeq': 2, + 'errorCode': 40904, + 'httpStatus': 409, + }); + }); + + test('createFailed:本地校验/网络失败无 errorCode 时可空属性整体缺席', () { + analytics.createFailed( + recordType: HealthRecordType.weight, + reason: HealthRecordFailureReason.networkError, + attemptSeq: 1, + ); + + expect(events.single.$2, { + 'recordType': 'weight', + 'failureReason': 'network_error', + 'attemptSeq': 1, + }); + }); + + test('viewed:recordType + source(health_event/reminder 取值随字典)', () { + analytics.viewed( + recordType: HealthRecordType.healthEvent, + source: HealthRecordViewSource.petDetail, + ); + + expect(events.single.$1, 'health_record_viewed'); + expect(events.single.$2, { + 'recordType': 'health_event', + 'source': 'pet_detail', + }); + }); +} diff --git a/test/features/pets/health_record_display_test.dart b/test/features/pets/health_record_display_test.dart new file mode 100644 index 0000000..24ffedc --- /dev/null +++ b/test/features/pets/health_record_display_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/features/pets/health_record_display.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; + +import '../../helpers/pet_test_helpers.dart'; + +void main() { + group('parseWeightKgInput(契约 (0,500] 两位小数)', () { + test('合法值:整数、一位、两位小数、上边界 500', () { + expect(parseWeightKgInput('5'), 5); + expect(parseWeightKgInput(' 4.5 '), 4.5); + expect(parseWeightKgInput('4.35'), 4.35); + expect(parseWeightKgInput('500'), 500); + expect(parseWeightKgInput('0.01'), 0.01); + }); + + test('非法值:0、越界、三位小数、非数字、负数、科学计数', () { + expect(parseWeightKgInput('0'), isNull); + expect(parseWeightKgInput('0.00'), isNull); + expect(parseWeightKgInput('500.01'), isNull); + expect(parseWeightKgInput('4.356'), isNull); + expect(parseWeightKgInput('abc'), isNull); + expect(parseWeightKgInput('-3'), isNull); + expect(parseWeightKgInput('1e2'), isNull); + expect(parseWeightKgInput(''), isNull); + }); + }); + + test('formatWeightKg:去尾零(4.35 / 4.5 / 5 / 400)', () { + expect(formatWeightKg(4.35), '4.35'); + expect(formatWeightKg(4.50), '4.5'); + expect(formatWeightKg(5.00), '5'); + expect(formatWeightKg(400), '400'); + }); + + group('疫苗展示映射', () { + test('状态标签与基色(TagPill 三态)', () { + expect(vaccinationStatusLabel(VaccinationStatus.scheduled), '计划中'); + expect(vaccinationStatusLabel(VaccinationStatus.completed), '已完成'); + expect(vaccinationStatusLabel(VaccinationStatus.cancelled), '已取消'); + expect( + vaccinationStatusColor(VaccinationStatus.scheduled), + AppColors.accent, + ); + expect( + vaccinationStatusColor(VaccinationStatus.completed), + AppColors.success, + ); + expect( + vaccinationStatusColor(VaccinationStatus.cancelled), + AppColors.muted, + ); + }); + + test('剂次标签:doseLabel 优先,缺席回落「第 N 针」', () { + expect(vaccinationDoseLabel(buildVaccination('vx-1')), '第 1 针'); + expect( + vaccinationDoseLabel( + buildVaccination('vx-2', overrides: {'doseLabel': '首免'}), + ), + '首免', + ); + }); + + test('日期副行:计划 / 接种+下次 / 已取消 三态', () { + expect(vaccinationDateLine(buildVaccination('vx-1')), '计划 2026-10-01'); + expect( + vaccinationDateLine( + buildVaccination( + 'vx-2', + overrides: { + 'status': 'completed', + 'plannedOn': null, + 'administeredOn': '2026-09-01', + 'nextDueOn': '2027-09-01', + }, + ), + ), + '接种 2026-09-01 · 下次 2027-09-01', + ); + expect( + vaccinationDateLine( + buildVaccination( + 'vx-3', + overrides: {'status': 'cancelled', 'plannedOn': null}, + ), + ), + '已取消', + ); + }); + }); + + group('vaccinationDateRuleError(42201 规则前端拦截)', () { + test('scheduled 缺 plannedOn 拦截;补齐通过', () { + expect( + vaccinationDateRuleError(status: VaccinationStatus.scheduled), + '请选择计划接种日期', + ); + expect( + vaccinationDateRuleError( + status: VaccinationStatus.scheduled, + plannedOn: DateTime(2026, 10), + ), + isNull, + ); + }); + + test('completed 缺 administeredOn 拦截(未填接种日期就标完成)', () { + expect( + vaccinationDateRuleError(status: VaccinationStatus.completed), + '请选择接种日期', + ); + expect( + vaccinationDateRuleError( + status: VaccinationStatus.completed, + administeredOn: DateTime(2026, 9, 1), + ), + isNull, + ); + }); + + test('nextDueOn 早于 administeredOn 拦截;同日与晚于通过', () { + expect( + vaccinationDateRuleError( + status: VaccinationStatus.completed, + administeredOn: DateTime(2026, 9, 8), + nextDueOn: DateTime(2026, 9, 7), + ), + '下次接种日期不能早于接种日期', + ); + expect( + vaccinationDateRuleError( + status: VaccinationStatus.completed, + administeredOn: DateTime(2026, 9, 8), + nextDueOn: DateTime(2026, 9, 8), + ), + isNull, + ); + expect( + vaccinationDateRuleError( + status: VaccinationStatus.completed, + administeredOn: DateTime(2026, 9, 8), + nextDueOn: DateTime(2027, 9, 8), + ), + isNull, + ); + }); + }); +} diff --git a/test/features/pets/weight_form_page_test.dart b/test/features/pets/weight_form_page_test.dart new file mode 100644 index 0000000..d9d229d --- /dev/null +++ b/test/features/pets/weight_form_page_test.dart @@ -0,0 +1,195 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; +import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; +import 'package:patbond_flutter/features/pets/weight_form_page.dart'; + +import '../../helpers/pet_test_helpers.dart'; + +void main() { + late FakePetsRepository repository; + late List<(String, Map?)> events; + late HealthRecordAnalytics analytics; + + setUp(() { + repository = FakePetsRepository(); + events = []; + analytics = HealthRecordAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + List?> eventsOf(String name) => [ + for (final e in events) + if (e.$1 == name) e.$2, + ]; + + Future pumpForm(WidgetTester tester) async { + tester.view.physicalSize = const Size(700, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: const Scaffold(body: Text('列表基底')), + ), + ); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => WeightFormPage( + repository: repository, + petId: 'p-1', + analytics: analytics, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Finder weightField() => find.widgetWithText(TextFormField, '体重(公斤)'); + + testWidgets('空值提交拦截 + failed(validation_error)', (tester) async { + await pumpForm(tester); + + await tester.tap(find.text('保存记录')); + await tester.pumpAndSettle(); + + expect(find.text('请输入体重'), findsOneWidget); + final failed = eventsOf('health_record_create_failed'); + expect(failed.single!['recordType'], 'weight'); + expect(failed.single!['failureReason'], 'validation_error'); + expect(failed.single!['attemptSeq'], 1); + }); + + testWidgets('契约区间前端拦截:>500 与三位小数均不放行、不发请求', (tester) async { + var createCalls = 0; + repository.createWeightHandler = (petId, request) async { + createCalls++; + return buildWeight('w-x'); + }; + await pumpForm(tester); + + await tester.enterText(weightField(), '500.5'); + await tester.tap(find.text('保存记录')); + await tester.pumpAndSettle(); + expect(find.text('体重需大于 0 且不超过 500 公斤,最多两位小数'), findsOneWidget); + + await tester.enterText(weightField(), '4.356'); + await tester.tap(find.text('保存记录')); + await tester.pumpAndSettle(); + expect(find.text('体重需大于 0 且不超过 500 公斤,最多两位小数'), findsOneWidget); + + expect(createCalls, 0); + expect(eventsOf('health_record_create_failed'), hasLength(2)); + }); + + testWidgets('首次输入触发 started(每次进入仅一次,recordType=weight)', (tester) async { + await pumpForm(tester); + + expect(eventsOf('health_record_create_started'), isEmpty); + + await tester.enterText(weightField(), '4'); + await tester.enterText(weightField(), '4.3'); + await tester.pumpAndSettle(); + + final started = eventsOf('health_record_create_started'); + expect(started, hasLength(1)); + expect(started.single, { + 'recordType': 'weight', + 'entryPoint': 'record_list', + }); + }); + + testWidgets('保存成功:请求对齐契约(UTC 时间戳、note 可选)、pop 回列表、succeeded', (tester) async { + CreateWeightRequest? captured; + repository.createWeightHandler = (petId, request) async { + expect(petId, 'p-1'); + captured = request; + return buildWeight('w-new', weightKg: 4.35); + }; + + await pumpForm(tester); + await tester.enterText(weightField(), '4.35'); + await tester.enterText( + find.widgetWithText(TextFormField, '备注(可选,如:饭后称重)'), + '饭后', + ); + await tester.tap(find.text('保存记录')); + await tester.pumpAndSettle(); + + final json = captured!.toJson(); + expect(json['weightKg'], 4.35); + expect(json['note'], '饭后'); + // 今日称重取此刻并转 UTC(ISO 带 Z 后缀,避免服务端时区歧义)。 + expect(json['measuredAt'], endsWith('Z')); + expect(json.containsKey('source'), isFalse); + + expect(find.text('列表基底'), findsOneWidget); + final succeeded = eventsOf('health_record_create_succeeded'); + expect(succeeded.single!['recordType'], 'weight'); + expect(succeeded.single!['durationMs'], isA()); + expect(succeeded.single!['photoCount'], 0); + }); + + testWidgets('40000 后端兜底:横幅提示 + failed 带 errorCode/httpStatus', ( + tester, + ) async { + repository.createWeightHandler = (petId, request) async => + throw const ApiBusinessException(code: 40000, message: '参数校验失败'); + + await pumpForm(tester); + await tester.enterText(weightField(), '4.35'); + await tester.tap(find.text('保存记录')); + await tester.pumpAndSettle(); + + expect(find.text('请检查填写内容后重试'), findsOneWidget); + expect(find.byType(WeightFormPage), findsOneWidget); + final failed = eventsOf('health_record_create_failed'); + expect(failed.single!['errorCode'], 40000); + expect(failed.single!['httpStatus'], 400); + expect(failed.single!['failureReason'], 'validation_error'); + }); + + testWidgets('40300 viewer 越权兜底:横幅 + failed(permission_denied)', ( + tester, + ) async { + repository.createWeightHandler = (petId, request) async => + throw const PetAccessDeniedException(message: '无权限'); + + await pumpForm(tester); + await tester.enterText(weightField(), '4.35'); + await tester.tap(find.text('保存记录')); + await tester.pumpAndSettle(); + + expect(find.text('你没有权限为该宠物添加记录'), findsOneWidget); + expect( + eventsOf('health_record_create_failed').single!['failureReason'], + 'permission_denied', + ); + }); + + testWidgets('网络异常:SnackBar + 重试动作 + failed(network_error)', (tester) async { + repository.createWeightHandler = (petId, request) async => + throw const ApiNetworkException('断网'); + + await pumpForm(tester); + await tester.enterText(weightField(), '4.35'); + await tester.tap(find.text('保存记录')); + await tester.pumpAndSettle(); + + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + expect( + eventsOf('health_record_create_failed').single!['failureReason'], + 'network_error', + ); + }); +} diff --git a/test/features/pets/weight_records_page_test.dart b/test/features/pets/weight_records_page_test.dart new file mode 100644 index 0000000..3cd0f13 --- /dev/null +++ b/test/features/pets/weight_records_page_test.dart @@ -0,0 +1,206 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; +import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; +import 'package:patbond_flutter/features/pets/weight_form_page.dart'; +import 'package:patbond_flutter/features/pets/weight_records_page.dart'; + +import '../../helpers/pet_test_helpers.dart'; + +void main() { + late FakePetsRepository repository; + late List<(String, Map?)> events; + late HealthRecordAnalytics analytics; + + setUp(() { + repository = FakePetsRepository(); + events = []; + analytics = HealthRecordAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + List?> eventsOf(String name) => [ + for (final e in events) + if (e.$1 == name) e.$2, + ]; + + Future pumpPage(WidgetTester tester, {bool canWrite = true}) async { + tester.view.physicalSize = const Size(700, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: const Scaffold(body: Text('详情基底')), + ), + ); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => WeightRecordsPage( + repository: repository, + petId: 'p-1', + canWrite: canWrite, + analytics: analytics, + ), + ), + ), + ); + await tester.pump(); + } + + CursorPage page( + List items, { + String? nextCursor, + bool hasMore = false, + }) => CursorPage(items: items, nextCursor: nextCursor, hasMore: hasMore); + + testWidgets('四态 · loading → ready:条目渲染体重值与时间;viewed 上报一次', (tester) async { + final completer = Completer>(); + repository.listWeightsHandler = (petId, limit, cursor) => completer.future; + + await pumpPage(tester); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete( + page([ + buildWeight('w-1', weightKg: 4.35), + buildWeight('w-2', weightKg: 4.2, overrides: {'note': '洗澡后'}), + ]), + ); + await tester.pumpAndSettle(); + + expect(find.text('4.35 kg'), findsOneWidget); + expect(find.text('4.2 kg'), findsOneWidget); + expect(find.textContaining('洗澡后'), findsOneWidget); + // 列表曝光(每次进入一次),source=pet_detail。 + final viewed = eventsOf('health_record_viewed'); + expect(viewed.single, {'recordType': 'weight', 'source': 'pet_detail'}); + }); + + testWidgets('四态 · empty:空态插画 + 记录 CTA(canWrite)', (tester) async { + repository.listWeightsHandler = (petId, limit, cursor) async => + page(const []); + + await pumpPage(tester); + await tester.pumpAndSettle(); + + expect(find.byType(EmptyStateIllustration), findsOneWidget); + expect(find.text('还没有体重记录'), findsOneWidget); + expect(find.text('记录第一条'), findsOneWidget); + }); + + testWidgets('四态 · error/retry:横幅 + 重试恢复', (tester) async { + var calls = 0; + repository.listWeightsHandler = (petId, limit, cursor) async { + calls++; + if (calls == 1) throw const ApiNetworkException('断网'); + return page([buildWeight('w-1')]); + }; + + await pumpPage(tester); + await tester.pumpAndSettle(); + + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(find.text('4.35 kg'), findsOneWidget); + // 失败后的首个成功加载才计一次曝光。 + expect(eventsOf('health_record_viewed'), hasLength(1)); + }); + + testWidgets('cursor 分页:加载更多追加下一页并透传游标,末页收起按钮', (tester) async { + final cursors = []; + repository.listWeightsHandler = (petId, limit, cursor) async { + cursors.add(cursor); + if (cursor == null) { + return page( + [buildWeight('w-1')], + nextCursor: 'CURSOR-1', + hasMore: true, + ); + } + return page([buildWeight('w-2', weightKg: 4.1)]); + }; + + await pumpPage(tester); + await tester.pumpAndSettle(); + + expect(find.text('加载更多'), findsOneWidget); + + await tester.tap(find.text('加载更多')); + await tester.pumpAndSettle(); + + expect(cursors, [null, 'CURSOR-1']); + expect(find.text('4.35 kg'), findsOneWidget); + expect(find.text('4.1 kg'), findsOneWidget); + expect(find.text('加载更多'), findsNothing); + }); + + testWidgets('加载更多失败:SnackBar 提示,按钮保留可再试', (tester) async { + var calls = 0; + repository.listWeightsHandler = (petId, limit, cursor) async { + calls++; + if (calls == 1) { + return page([buildWeight('w-1')], nextCursor: 'C1', hasMore: true); + } + throw const ApiNetworkException('断网'); + }; + + await pumpPage(tester); + await tester.pumpAndSettle(); + + await tester.tap(find.text('加载更多')); + await tester.pumpAndSettle(); + + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + expect(find.text('加载更多'), findsOneWidget); + }); + + testWidgets('viewer(canWrite=false):无添加入口、空态无 CTA', (tester) async { + repository.listWeightsHandler = (petId, limit, cursor) async => + page(const []); + + await pumpPage(tester, canWrite: false); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.add), findsNothing); + expect(find.text('记录第一条'), findsNothing); + }); + + testWidgets('录入闭环:添加 → 表单(record_form 路由名)→ 成功后插入列表头', (tester) async { + repository.listWeightsHandler = (petId, limit, cursor) async => + page([buildWeight('w-1')]); + repository.createWeightHandler = (petId, request) async => + buildWeight('w-new', weightKg: 4.6); + + await pumpPage(tester); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + expect(find.byType(WeightFormPage), findsOneWidget); + final route = ModalRoute.of(tester.element(find.byType(WeightFormPage)))!; + expect(route.settings.name, 'record_form'); + + await tester.enterText(find.widgetWithText(TextFormField, '体重(公斤)'), '4.6'); + await tester.tap(find.text('保存记录')); + await tester.pumpAndSettle(); + + expect(find.byType(WeightFormPage), findsNothing); + expect(find.text('已记录体重'), findsOneWidget); + expect(find.text('4.6 kg'), findsOneWidget); + expect(find.text('4.35 kg'), findsOneWidget); + }); +} diff --git a/test/helpers/pet_test_helpers.dart b/test/helpers/pet_test_helpers.dart index ebf61ed..fcfe916 100644 --- a/test/helpers/pet_test_helpers.dart +++ b/test/helpers/pet_test_helpers.dart @@ -129,6 +129,44 @@ Pet buildPet( ), ); +Map sampleVaccineCatalogJson({ + String id = 'v-1', + String code = 'rabies', + String name = '狂犬疫苗', + String species = 'dog', +}) => { + 'id': id, + 'code': code, + 'name': name, + 'species': species, + 'description': null, +}; + +/// 快速构造体重记录(widget 测试共用)。 +WeightRecord buildWeight( + String id, { + double weightKg = 4.35, + String measuredAt = '2026-09-07T09:00:00+08:00', + Map overrides = const {}, +}) => WeightRecord.fromJson({ + ...sampleWeightJson(), + 'id': id, + 'weightKg': weightKg, + 'measuredAt': measuredAt, + ...overrides, +}); + +/// 快速构造疫苗记录。 +Vaccination buildVaccination( + String id, { + Map overrides = const {}, +}) => + Vaccination.fromJson({...sampleVaccinationJson(), 'id': id, ...overrides}); + +/// 快速构造摘要(缺省为「三聚合齐备」样本;overrides 可置 null 验证空态)。 +PetSummary buildSummary({Map overrides = const {}}) => + PetSummary.fromJson({...samplePetSummaryJson(), ...overrides}); + /// 假仓库:各方法可注入行为;listPets 默认空列表,其余未注入的方法抛 /// UnimplementedError(22 号报告 §7:widget 测试注入假仓库的共享实现)。 class FakePetsRepository implements PetsRepository { @@ -137,6 +175,16 @@ class FakePetsRepository implements PetsRepository { Future Function(String)? getPetHandler; Future Function(String, UpdatePetRequest)? updatePetHandler; Future> Function(PetSpecies?)? listBreedsHandler; + Future> Function(String, int?, String?)? + listWeightsHandler; + Future Function(String, CreateWeightRequest)? + createWeightHandler; + Future> Function(PetSpecies?)? + listVaccineCatalogHandler; + Future> Function(String)? listVaccinationsHandler; + Future Function(String, CreateVaccinationRequest)? + createVaccinationHandler; + Future Function(String, String?)? getPetSummaryHandler; @override Future> listPets() => @@ -157,6 +205,53 @@ class FakePetsRepository implements PetsRepository { listBreedsHandler?.call(species) ?? Future.value([Breed.fromJson(sampleBreedJson())]); + @override + Future> listWeights( + String petId, { + int? limit, + String? cursor, + }) => listWeightsHandler!(petId, limit, cursor); + + @override + Future createWeight( + String petId, + CreateWeightRequest request, + ) => createWeightHandler!(petId, request); + + @override + Future> listVaccineCatalog({PetSpecies? species}) => + listVaccineCatalogHandler?.call(species) ?? + Future.value([VaccineCatalogItem.fromJson(sampleVaccineCatalogJson())]); + + @override + Future> listVaccinations(String petId) => + listVaccinationsHandler!(petId); + + @override + Future createVaccination( + String petId, + CreateVaccinationRequest request, + ) => createVaccinationHandler!(petId, request); + + @override + Future getPetSummary(String petId, {String? tz}) => + getPetSummaryHandler?.call(petId, tz) ?? + // 缺省给「无任何记录」空摘要(契约 null 语义),既有详情页测试 + // 不必逐个注入。 + Future.value( + PetSummary.fromJson({ + 'petId': petId, + 'latestWeight': null, + 'vaccinationProgress': null, + 'nextVaccination': null, + 'monthlyExpense': { + 'month': '2026-09', + 'timezone': 'UTC', + 'amountCents': 0, + }, + }), + ); + @override dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError('${invocation.memberName}');