From 97a1f46e3fbfe8be5326974198c38526232df02e Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Tue, 8 Sep 2026 12:15:32 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E5=AE=A0=E7=89=A9?= =?UTF-8?q?=E5=88=97=E8=A1=A8/=E8=AF=A6=E6=83=85/=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E9=A1=B5=E6=8E=A5=E5=85=A5=E7=9C=9F=E5=AE=9E=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=EF=BC=88T2-12=EF=BC=8C=E5=9B=9B=E6=80=81=E9=BD=90=E5=A4=87?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 档案 Tab 替换为真实宠物列表(05 号规范 P1):PetsController 四态 驱动(loading/empty/error+重试/ready),空态插画 + 建档 CTA, 下拉刷新,虚线添加卡;页面一律走 T2-11 数据层、不直连 ApiClient - 新增 PetDetailPage(P2 档案信息部分):内存副本首屏 + getPet 刷新, 40401 防枚举 → 不存在态返回列表并刷新;仅 owner 显示编辑入口 (40300 语义);体重/疫苗/时间线区块留待 T2-13/14 接摘要与记录接口 - 新增 PetFormPage(建档/编辑):品种目录接口 + 自定义品种互斥 (目录失败回落自定义 + 重试)、性别契约必填、生日+估算、芯片号; 失焦+提交双校验、错误三层模型沿用登录纵切 - 40902 版本冲突:明确提示 + 自动拉取最新 version 重提路径(有测试); 40903 芯片号冲突字段级报错(有测试) - 埋点挂接:pet_create_started(表单首次输入、每次进入一次)/ succeeded / failed 三事件接线;建宠表单路由名 pet_form、详情页 pet_detail 进入既有 RouteObserver 采集;档案 Tab 页名 pet_archive 改报 pet_list(字典 v2);编辑表单不带路由名(漏斗到达段不掺编辑) - app.dart 装配:pet 服务 ApiClient(:8083)共享 TokenRefresher; 登出 reset 控制器防跨账号泄漏;PetsController.loadBreeds 按物种缓存 - AppState 清理:pets 页原 demo 消费方移除,vaccines 及其模型/持久化 删除;pet demo 仅存首页问候/创作页/主壳头像(后续工单收敛) - 头像按 ADR-010 本地占位,不做上传 - 测试 145 → 177 全绿(列表/详情/表单四态与冲突路径 widget 测试、 控制器 loadBreeds/reset、展示纯函数);analyze 0;format 无 diff Co-Authored-By: Claude Fable 5 --- lib/app/app.dart | 49 +- lib/data/demo_data.dart | 27 - lib/features/main/main_shell_page.dart | 20 +- lib/features/pets/pet_detail_page.dart | 286 ++++++ lib/features/pets/pet_display.dart | 56 ++ lib/features/pets/pet_form_page.dart | 714 ++++++++++++++ lib/features/pets/pets_controller.dart | 21 + lib/features/pets/pets_page.dart | 927 +++++-------------- lib/models/models.dart | 79 -- lib/state/app_state.dart | 25 +- test/features/pets/pet_detail_page_test.dart | 179 ++++ test/features/pets/pet_display_test.dart | 58 ++ test/features/pets/pet_form_page_test.dart | 345 +++++++ test/features/pets/pets_controller_test.dart | 103 ++- test/features/pets/pets_page_test.dart | 147 +++ test/helpers/pet_test_helpers.dart | 58 +- test/widget_test.dart | 7 +- 17 files changed, 2249 insertions(+), 852 deletions(-) create mode 100644 lib/features/pets/pet_detail_page.dart create mode 100644 lib/features/pets/pet_display.dart create mode 100644 lib/features/pets/pet_form_page.dart create mode 100644 test/features/pets/pet_detail_page_test.dart create mode 100644 test/features/pets/pet_display_test.dart create mode 100644 test/features/pets/pet_form_page_test.dart create mode 100644 test/features/pets/pets_page_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 0274338..90a34fd 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -13,14 +13,23 @@ 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/main/main_shell_page.dart'; +import 'package:patbond_flutter/features/pets/pet_analytics.dart'; +import 'package:patbond_flutter/features/pets/pets_controller.dart'; +import 'package:patbond_flutter/features/pets/pets_repository.dart'; import 'package:patbond_flutter/state/app_state.dart'; class App extends StatefulWidget { - const App({super.key, this.sessionManager, this.authRepository}); + const App({ + super.key, + this.sessionManager, + this.authRepository, + this.petsRepository, + }); /// 测试注入口;生产默认走安全存储 + 真实 API。 final SessionManager? sessionManager; final AuthRepository? authRepository; + final PetsRepository? petsRepository; @override State createState() => _AppState(); @@ -30,10 +39,13 @@ class _AppState extends State { late final AppState appState; late final SessionManager sessionManager; late final AuthRepository authRepository; + late final PetsController petsController; + late final PetAnalytics petAnalytics; late final SessionTracker _sessionTracker; late final AnalyticsService _analytics; late final PageViewTracker _pageViewTracker; late final AnalyticsRouteObserver _routeObserver; + TokenRefresher? _sharedRefresher; @override void initState() { @@ -64,6 +76,10 @@ class _AppState extends State { ); authRepository = widget.authRepository ?? _buildRepository(); + petsController = PetsController( + repository: widget.petsRepository ?? _buildPetsRepository(), + ); + petAnalytics = PetAnalytics(_analytics.trackEvent); // 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件) sessionManager.addListener(_reportAuthStateChange); @@ -79,9 +95,18 @@ class _AppState extends State { } } + /// 三服务分端口直连(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 = TokenRefresher(dio: dio, session: sessionManager); + final refresher = _ensureRefresher(); final api = ApiClient( dio: dio, session: sessionManager, @@ -95,6 +120,19 @@ class _AppState extends State { ); } + PetsRepository _buildPetsRepository() { + final dio = buildPatbondDio( + session: sessionManager, + baseUrl: patbondPetApiBaseUrl, + ); + final api = ApiClient( + dio: dio, + session: sessionManager, + refresher: _ensureRefresher(), + ); + return ApiPetsRepository(api: api); + } + AnalyticsPageName? _resolveRootPage() { // 回栈到无名根路由时解析当前认证状态页/主壳 Tab return switch (sessionManager.status) { @@ -105,6 +143,10 @@ class _AppState extends State { } void _reportAuthStateChange() { + // 登出即清宠物档案内存副本(跨账号不泄漏;重登后列表页重新拉取)。 + if (sessionManager.status == AuthStatus.unauthenticated) { + petsController.reset(); + } // 认证状态机切页补点(03 §3.2 非路由曝光 1/2) final page = switch (sessionManager.status) { AuthStatus.unauthenticated => AnalyticsPageName.login, @@ -119,6 +161,7 @@ class _AppState extends State { sessionManager.removeListener(_reportAuthStateChange); WidgetsBinding.instance.removeObserver(_sessionTracker); appState.dispose(); + petsController.dispose(); if (widget.sessionManager == null) sessionManager.dispose(); super.dispose(); } @@ -140,6 +183,8 @@ class _AppState extends State { return MainShellPage( key: const ValueKey('shell'), appState: appState, + petsController: petsController, + petAnalytics: petAnalytics, pageViewTracker: _pageViewTracker, onLogout: authRepository.logout, ); diff --git a/lib/data/demo_data.dart b/lib/data/demo_data.dart index 4b22afd..ebe3aab 100644 --- a/lib/data/demo_data.dart +++ b/lib/data/demo_data.dart @@ -82,33 +82,6 @@ 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', diff --git a/lib/features/main/main_shell_page.dart b/lib/features/main/main_shell_page.dart index ae0380a..93349a4 100644 --- a/lib/features/main/main_shell_page.dart +++ b/lib/features/main/main_shell_page.dart @@ -4,6 +4,8 @@ import 'package:patbond_flutter/analytics/page_view_tracker.dart'; import 'package:patbond_flutter/core/theme/app_theme.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/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_page.dart'; @@ -15,13 +17,21 @@ import 'package:patbond_flutter/widgets/common.dart'; class MainShellPage extends StatefulWidget { const MainShellPage({ required this.appState, + required this.petsController, super.key, + this.petAnalytics, this.pageViewTracker, this.onLogout, }); final AppState appState; + /// 宠物档案状态(T2-11 拆出的独立 pets feature;档案 Tab 数据源)。 + final PetsController petsController; + + /// pet 域埋点强类型封装(建宠漏斗三事件)。 + final PetAnalytics? petAnalytics; + /// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。 final PageViewTracker? pageViewTracker; @@ -38,11 +48,12 @@ class _MainShellPageState extends State { static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料']; - /// Tab 索引 → pageName 枚举(03 号评估 §3.2 映射表)。 + /// Tab 索引 → pageName 枚举(03 号评估 §3.2 映射表;档案 Tab 自 T2-12 + /// 起为真实宠物列表,页名由 pet_archive 改报 pet_list,06 §5.2 字典 v2)。 static const _tabPages = [ AnalyticsPageName.home, AnalyticsPageName.create, - AnalyticsPageName.petArchive, + AnalyticsPageName.petList, AnalyticsPageName.services, AnalyticsPageName.profile, ]; @@ -104,7 +115,10 @@ class _MainShellPageState extends State { ); }, ), - PetsPage(appState: widget.appState), + PetsPage( + controller: widget.petsController, + analytics: widget.petAnalytics, + ), ServicesPage( showPersonal: showPersonalServices, locationWeather: widget.appState.locationWeather, diff --git a/lib/features/pets/pet_detail_page.dart b/lib/features/pets/pet_detail_page.dart new file mode 100644 index 0000000..0420a4e --- /dev/null +++ b/lib/features/pets/pet_detail_page.dart @@ -0,0 +1,286 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/navigation/fade_route.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; +import 'package:patbond_flutter/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/widgets/common.dart'; + +enum _DetailPhase { loading, ready, error, notFound } + +/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。 +/// +/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet] +/// 拉取最新详情(同步列表副本)。四态:loading / ready / error+重试 / +/// notFound(40401 防枚举三态同响应 → 提示后返回列表并刷新)。 +/// +/// 体重、疫苗进度、健康时间线与提醒区块随 T2-13/14 接入摘要与 +/// 记录接口后补充,本单不渲染 demo 占位。 +class PetDetailPage extends StatefulWidget { + const PetDetailPage({ + required this.controller, + required this.petId, + super.key, + this.analytics, + }); + + final PetsController controller; + final String petId; + final PetAnalytics? analytics; + + @override + State createState() => _PetDetailPageState(); +} + +class _PetDetailPageState extends State { + _DetailPhase _phase = _DetailPhase.loading; + Pet? _pet; + ApiException? _error; + + @override + void initState() { + super.initState(); + _pet = _fromController(); + if (_pet != null) _phase = _DetailPhase.ready; + _load(); + } + + Pet? _fromController() { + for (final pet in widget.controller.pets) { + if (pet.id == widget.petId) return pet; + } + return null; + } + + Future _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)))); + } + } + } + + Future _openEdit() async { + final pet = _pet; + if (pet == null) return; + final updated = await Navigator.of(context).push( + // 编辑态不带路由名:pet_form 专属建宠漏斗到达段(06 §1.6), + // 编辑曝光不计入,避免「到达→动笔」分母虚高。 + fadePageRoute( + PetFormPage.edit( + controller: widget.controller, + pet: pet, + analytics: widget.analytics, + ), + ), + ); + if (updated != null && mounted) { + setState(() { + _pet = updated; + _phase = _DetailPhase.ready; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('已保存修改'))); + } + } + + /// 仅 owner 可改档案(40300:caregiver/viewer 改档案被拒 → 隐藏写入口)。 + bool get _canEdit => + _phase == _DetailPhase.ready && _pet?.myRole == PetRole.owner; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + foregroundColor: AppColors.ink, + actions: [ + if (_canEdit) + IconButton( + tooltip: '编辑资料', + onPressed: _openEdit, + icon: const Icon(Icons.edit_outlined), + ), + ], + ), + body: switch (_phase) { + _DetailPhase.loading => const Center( + child: CircularProgressIndicator(), + ), + _DetailPhase.error => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InlineErrorBanner(message: petLoadErrorMessage(_error)), + const SizedBox(height: 16), + FilledButton(onPressed: _load, child: const Text('重试')), + ], + ), + ), + ), + _DetailPhase.notFound => Center( + child: SingleChildScrollView( + child: EmptyStateIllustration( + icon: Icons.search_off_rounded, + title: '宠物不存在或已被删除', + description: '档案可能已被移除,返回列表查看最新档案', + ctaLabel: '返回列表', + onCtaPressed: () { + widget.controller.refresh(); + Navigator.of(context).pop(); + }, + ), + ), + ), + _DetailPhase.ready => _content(_pet!), + }, + ); + } + + Widget _content(Pet pet) { + return ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 30), + children: [ + Column( + children: [ + PetAvatar( + size: PetAvatarSize.xl, + showEditBadge: _canEdit, + onTap: _canEdit ? _openEdit : null, + semanticLabel: _canEdit ? '编辑宠物资料' : null, + ), + const SizedBox(height: 12), + Text(pet.name, style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 4), + Text( + petMetaLine(pet), + style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), + ), + if (pet.status != PetStatus.active) ...[ + const SizedBox(height: 8), + TagPill( + petStatusLabel(pet.status), + color: pet.status == PetStatus.lost + ? AppColors.error + : AppColors.muted, + ), + ], + ], + ), + const SizedBox(height: 24), + Text('基本资料', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 10), + 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('编辑资料'), + ), + ], + ], + ); + } +} + +/// 键值行(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); + } +} diff --git a/lib/features/pets/pet_display.dart b/lib/features/pets/pet_display.dart new file mode 100644 index 0000000..2f416b9 --- /dev/null +++ b/lib/features/pets/pet_display.dart @@ -0,0 +1,56 @@ +/// 宠物档案展示文案的纯函数集合(列表 / 详情 / 表单共用,可单测)。 +library; + +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; + +String petSpeciesLabel(PetSpecies species) => switch (species) { + PetSpecies.dog => '狗狗', + PetSpecies.cat => '猫咪', + PetSpecies.other => '其他', +}; + +String petSexLabel(PetSex sex) => switch (sex) { + PetSex.male => '男孩', + PetSex.female => '女孩', + PetSex.unknown => '性别未知', +}; + +String petStatusLabel(PetStatus status) => switch (status) { + PetStatus.active => '正常', + PetStatus.lost => '走失中', + PetStatus.deceased => '已离世', + PetStatus.archived => '已归档', +}; + +/// 品种展示:目录品种名 > 自定义品种名 > 未知(Pet 响应恰有其一非空, +/// 双空为兜底)。 +String petBreedLabel(Pet pet) => + pet.breedDisplayName ?? pet.customBreedName ?? '品种未知'; + +/// 年龄:满一岁按「N 岁」、不满一岁按「N 个月」、当月新生「未满月」; +/// 生日缺席或晚于今天为「年龄未知」。 +String petAgeLabel(DateTime? birthDate, {DateTime? now}) { + if (birthDate == null) return '年龄未知'; + final today = now ?? DateTime.now(); + var months = + (today.year - birthDate.year) * 12 + today.month - birthDate.month; + if (today.day < birthDate.day) months--; + if (months < 0) return '年龄未知'; + if (months == 0) return '未满月'; + if (months < 12) return '$months 个月'; + return '${months ~/ 12} 岁'; +} + +/// 列表卡 / 详情头的元信息行:「柴犬 · 男孩 · 2 岁」。 +String petMetaLine(Pet pet, {DateTime? now}) => + '${petBreedLabel(pet)} · ${petSexLabel(pet.sex)} · ' + '${petAgeLabel(pet.birthDate, now: now)}'; + +/// 加载失败的用户文案(一迭代三层错误模型:不可归属字段的加载错误 +/// 走横幅;服务端原始 message 不上屏)。 +String petLoadErrorMessage(ApiException? error) => switch (error) { + ApiNetworkException _ => '网络异常,请检查网络后重试', + ApiRateLimitException _ => '请求过于频繁,请稍后再试', + _ => '加载失败,请稍后重试', +}; diff --git a/lib/features/pets/pet_form_page.dart b/lib/features/pets/pet_form_page.dart new file mode 100644 index 0000000..8c9632f --- /dev/null +++ b/lib/features/pets/pet_form_page.dart @@ -0,0 +1,714 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_text_field.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; +import 'package:patbond_flutter/core/widgets/primary_button.dart'; +import 'package:patbond_flutter/features/pets/pet_analytics.dart'; +import 'package:patbond_flutter/features/pets/pet_display.dart'; +import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; +import 'package:patbond_flutter/features/pets/pets_controller.dart'; + +/// 品种下拉里「自定义品种」哨兵值(与目录 breedId 互斥)。 +const _customBreedSentinel = '__custom__'; + +/// 建宠 / 编辑宠物资料表单页(T2-12)。 +/// +/// - 字段对齐冻结契约:昵称*、物种*(创建后不可改)、性别*(契约必填)、 +/// 品种(目录选择与自定义互斥、整体替换)、生日(+是否估算)、 +/// 芯片号、性格;头像按 ADR-010 本地占位、不做上传。 +/// - 错误分层沿用登录纵切:失焦+提交双校验走 errorText(onChanged 即清)、 +/// 不可归属错误走 InlineErrorBanner、网络瞬态走 SnackBar+重试。 +/// - 409/40902 版本冲突:提示「已被修改」并自动拉取最新版本(保留用户 +/// 输入、更新乐观锁 version),用户核对后重新保存;40903 芯片号冲突 +/// 为字段级报错。 +/// - 埋点(仅创建模式):首次输入触发 pet_create_started;成功/失败 +/// 经 [PetAnalytics] 强类型上报。表单页路由名 pet_form 由调用方在 +/// push 时给定(创建态),page_viewed 走既有 RouteObserver。 +class PetFormPage extends StatefulWidget { + const PetFormPage.create({ + required this.controller, + super.key, + this.analytics, + this.entryPoint = PetCreateEntryPoint.petList, + }) : pet = null; + + const PetFormPage.edit({ + required this.controller, + required Pet this.pet, + super.key, + this.analytics, + }) : entryPoint = null; + + final PetsController controller; + + /// null 为创建模式。 + final Pet? pet; + + final PetAnalytics? analytics; + + /// 创建模式的入口(pet_create_started.entryPoint)。 + final PetCreateEntryPoint? entryPoint; + + bool get isCreate => pet == null; + + @override + State createState() => _PetFormPageState(); +} + +class _PetFormPageState extends State { + 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? _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 _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 _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 _create() async { + final request = CreatePetRequest( + name: _nameCtrl.text.trim(), + species: _species, + sex: _sex!, + breedId: _isCustomBreed ? null : _breedChoice, + customBreedName: _isCustomBreed ? _customBreedCtrl.text.trim() : null, + birthDate: _birthDate, + birthDateEstimated: _birthDate != null ? _birthDateEstimated : null, + microchipNo: _textOrNull(_microchipCtrl), + personality: _textOrNull(_personalityCtrl), + ); + // petIndex:该用户第几只宠物(H2 假设数据源)。 + final petIndex = widget.controller.pets.length + 1; + final pet = await widget.controller.createPet(request); + widget.analytics?.createSucceeded( + durationMs: DateTime.now().difference(_openedAt).inMilliseconds, + species: _species, + petIndex: petIndex, + ); + if (mounted) Navigator.of(context).pop(pet); + } + + Future _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 _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( + 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( + 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 _breedSection() { + if (_breedsLoading) { + return const [ + SizedBox( + height: 52, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + ]; + } + + final widgets = []; + 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 []; + final knownIds = breeds.map((breed) => breed.id).toSet(); + final base = _basePet; + widgets + ..add( + DropdownButtonFormField( + initialValue: _breedChoice, + decoration: InputDecoration( + labelText: '品种', + errorText: _isCustomBreed ? null : _breedError, + ), + items: [ + // 编辑时目录中缺席的既有品种保底成项,避免下拉值失配。 + if (base?.breedId != null && !knownIds.contains(base!.breedId)) + DropdownMenuItem( + value: base.breedId, + child: Text(base.breedDisplayName ?? '当前品种'), + ), + for (final breed in breeds) + DropdownMenuItem( + value: breed.id, + child: Text(breed.displayName), + ), + const DropdownMenuItem( + value: _customBreedSentinel, + child: Text('自定义品种…'), + ), + ], + onChanged: _submitting + ? null + : (value) { + _markStarted(); + setState(() { + _breedChoice = value; + _breedError = null; + _formError = null; + }); + }, + ), + ) + ..add(const SizedBox(height: 12)); + } + + if (_isCustomBreed) { + widgets.add( + Focus( + onFocusChange: (hasFocus) { + if (!hasFocus && mounted) { + setState(() => _breedError = _validateBreed()); + } + }, + child: AppTextField( + label: '品种名称', + controller: _customBreedCtrl, + prefixIcon: Icons.edit_note_outlined, + errorText: _breedError, + enabled: !_submitting, + textInputAction: TextInputAction.next, + onChanged: (_) { + _markStarted(); + if (_breedError != null || _formError != null) { + _clearError(() => _breedError = null); + } + }, + ), + ), + ); + } else if (_breedError != null && _breedsFailed) { + widgets.add(_FieldError(_breedError!)); + } + return widgets; + } + + Widget _dateTile() { + return ListTile( + shape: RoundedRectangleBorder( + side: const BorderSide(color: AppColors.border), + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + tileColor: AppColors.surface, + leading: const Icon(Icons.cake_outlined, color: AppColors.muted), + title: const Text('生日(可选)', style: TextStyle(fontSize: 14)), + subtitle: Text( + _birthDate == null ? '未填写' : dateToJson(_birthDate!), + style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), + ), + trailing: const Icon( + Icons.calendar_month_outlined, + color: AppColors.muted, + ), + enabled: !_submitting, + onTap: () async { + final now = DateTime.now(); + final value = await showDatePicker( + context: context, + initialDate: _birthDate ?? DateTime(now.year - 1, now.month), + firstDate: DateTime(1990), + lastDate: now, + ); + if (value != null && mounted) { + _markStarted(); + setState(() => _birthDate = value); + } + }, + ); + } +} + +class _FieldLabel extends StatelessWidget { + const _FieldLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: const TextStyle( + color: AppColors.inkSoft, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ); + } +} + +class _FieldError extends StatelessWidget { + const _FieldError(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: const TextStyle(color: AppColors.error, fontSize: 12), + ); + } +} diff --git a/lib/features/pets/pets_controller.dart b/lib/features/pets/pets_controller.dart index 98a4e0f..0eb5620 100644 --- a/lib/features/pets/pets_controller.dart +++ b/lib/features/pets/pets_controller.dart @@ -25,6 +25,7 @@ class PetsController extends ChangeNotifier { List _pets = const []; ApiException? _lastError; bool _disposed = false; + final Map> _breedsCache = {}; PetsLoadPhase get phase => _phase; @@ -84,6 +85,26 @@ class PetsController extends ChangeNotifier { _notify(); } + /// 品种目录(只读字典):按物种缓存,会话内目录不变; + /// 失败按类型化异常外抛,供表单层内联重试。 + Future> 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(); } diff --git a/lib/features/pets/pets_page.dart b/lib/features/pets/pets_page.dart index c54d362..9d0921f 100644 --- a/lib/features/pets/pets_page.dart +++ b/lib/features/pets/pets_page.dart @@ -1,288 +1,214 @@ 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/data/demo_data.dart'; -import 'package:patbond_flutter/models/models.dart'; -import 'package:patbond_flutter/state/app_state.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/pet_analytics.dart'; +import 'package:patbond_flutter/features/pets/pet_detail_page.dart'; +import 'package:patbond_flutter/features/pets/pet_display.dart'; +import 'package:patbond_flutter/features/pets/pet_form_page.dart'; +import 'package:patbond_flutter/features/pets/pets_controller.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; import 'package:patbond_flutter/widgets/common.dart'; -class PetsPage extends StatelessWidget { - const PetsPage({required this.appState, super.key}); +/// 档案 Tab 落地页:宠物列表(T2-12 / 05 号规范 §4.1 P1)。 +/// +/// 真实数据经 [PetsController] 四态驱动:loading(居中转圈)、 +/// empty(空态插画 + 建档 CTA)、error(横幅 + 重试)、ready(列表)。 +/// 页面不直连 ApiClient,不读写 AppState demo 数据。 +class PetsPage extends StatefulWidget { + const PetsPage({required this.controller, super.key, this.analytics}); - final AppState appState; + final PetsController controller; + final PetAnalytics? analytics; - 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--; + @override + State createState() => _PetsPageState(); +} + +class _PetsPageState extends State { + @override + void initState() { + super.initState(); + // 主壳挂载即预取(IndexedStack 各 Tab 同时构建);重登后控制器 + // 已被 reset 回 initial,会重新拉取。 + if (widget.controller.phase == PetsLoadPhase.initial) { + widget.controller.refresh(); } - return '${age < 0 ? 0 : age} 岁'; } - Future editPet(BuildContext context) async { - final value = await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - builder: (context) => EditPetSheet(pet: appState.pet), + Future _openCreate(PetCreateEntryPoint entryPoint) async { + final created = await Navigator.of(context).push( + fadePageRoute( + PetFormPage.create( + controller: widget.controller, + analytics: widget.analytics, + entryPoint: entryPoint, + ), + // 建宠表单曝光由既有 RouteObserver 采集(06 §1.6 pet_form)。 + settings: RouteSettings(name: AnalyticsPageName.petForm.pageName), + ), ); - if (value != null) await appState.updatePet(value); + if (created != null && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('已为「${created.name}」建立档案 🐾'))); + } } - Future editVaccines(BuildContext context) async { - final value = await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - builder: (context) => VaccineSheet(record: appState.vaccines), + void _openDetail(Pet pet) { + Navigator.of(context).push( + fadePageRoute( + PetDetailPage( + controller: widget.controller, + petId: pet.id, + analytics: widget.analytics, + ), + settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName), + ), ); - if (value != null) await appState.updateVaccines(value); } @override Widget build(BuildContext context) { - final pet = appState.pet; - final vaccines = appState.vaccines; - final progress = vaccines.totalDoses == 0 - ? 0.0 - : vaccines.completedDoses / vaccines.totalDoses; + 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); + } + }, + ); + } - return ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 30), - children: [ - Column( - children: [ - 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), - 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, - ), + Widget _petList(List pets) { + return RefreshIndicator( + onRefresh: widget.controller.refresh, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 30), + children: [ + Row( + 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('添加'), + ), + ], + ), + const SizedBox(height: 12), + for (final pet in pets) ...[ + _PetCard(pet: pet, onTap: () => _openDetail(pet)), const SizedBox(height: 10), - OutlinedButton.icon( - onPressed: () => editPet(context), - icon: const Icon(Icons.settings_outlined, size: 17), - label: const Text('编辑资料'), - ), ], - ), - const SizedBox(height: 24), - Row( - children: [ - Text('宠物数据', style: Theme.of(context).textTheme.titleLarge), - const Spacer(), - TextButton.icon( - onPressed: () => editPet(context), - icon: const Icon(Icons.edit_outlined, size: 16), - label: const Text('编辑'), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _StatCard( - icon: Icons.monitor_weight_outlined, - color: AppColors.primary, - value: '${pet.weight.toStringAsFixed(1)}kg', - label: '体重', - ), - ), - const SizedBox(width: 10), - Expanded( - child: _StatCard( - icon: Icons.vaccines_outlined, - color: AppColors.success, - value: '${vaccines.completedDoses}/${vaccines.totalDoses}', - label: '疫苗进度', - onTap: () => editVaccines(context), - ), - ), - const SizedBox(width: 10), - const Expanded( - child: _StatCard( - icon: Icons.payments_outlined, - color: AppColors.warning, - value: '¥328', - label: '本月花费', - ), - ), - ], - ), - const SizedBox(height: 18), - SectionCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text('健康概览', style: Theme.of(context).textTheme.titleMedium), - const Spacer(), - Text('更新于今日', style: Theme.of(context).textTheme.bodySmall), - ], - ), - const SizedBox(height: 18), - Row( - children: [ - SizedBox.square( - dimension: 62, - child: Stack( - fit: StackFit.expand, - children: [ - CircularProgressIndicator( - value: progress, - strokeWidth: 7, - backgroundColor: AppColors.border, - color: AppColors.success, - ), - Center( - child: Text( - '${(progress * 100).round()}%', - style: const TextStyle( - color: AppColors.success, - fontWeight: FontWeight.w800, - ), - ), - ), - ], - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - progress >= 1 ? '疫苗接种完成' : '疫苗接种进行中', - style: const TextStyle(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 5), - Text( - '下一针:${vaccines.reminderVaccine}\n预计 ${vaccines.reminderDate}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - IconButton( - tooltip: '管理疫苗', - onPressed: () => editVaccines(context), - icon: const Icon(Icons.edit_outlined), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: AppColors.successSurface, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: AppColors.success.withAlpha(140)), - ), - child: const Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.auto_awesome, color: AppColors.success), - SizedBox(width: 10), - Expanded( - child: Text( - '健康提醒:已经半年没有进行体内外驱虫,建议本周安排一次。', - style: TextStyle(color: AppColors.successInk, height: 1.5), - ), - ), - ], - ), - ), - const SizedBox(height: 22), - Text('成长足迹', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 10), - const _TimelineTile( - icon: Icons.medical_services_outlined, - title: '医疗 · 狂犬疫苗接种', - subtitle: '2025-06-12 · 瑞派宠物医院', - status: '已完成', - ), - const _TimelineTile( - icon: Icons.restaurant_outlined, - title: '喂养 · 更换幼犬粮', - subtitle: '2025-05-20 · 体重增长稳定', - status: '已记录', - ), - ], + _AddPetCard(onTap: () => _openCreate(PetCreateEntryPoint.petList)), + ], + ), ); } } -class _StatCard extends StatelessWidget { - const _StatCard({ - required this.icon, - required this.color, - required this.value, - required this.label, - this.onTap, - }); +/// 加载失败态:InlineErrorBanner + 重试按钮(05 §4.2 错误三层模型)。 +class _LoadErrorView extends StatelessWidget { + const _LoadErrorView({required this.message, required this.onRetry}); - final IconData icon; - final Color color; - final String value; - final String label; - final VoidCallback? onTap; + final String message; + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InlineErrorBanner(message: message), + const SizedBox(height: 16), + FilledButton(onPressed: onRetry, child: const Text('重试')), + ], + ), + ), + ); + } +} + +/// 宠物卡(05 §4.1):头像 lg + 名字 + 元信息 + chevron;非 active +/// 状态以 TagPill 标示(图文双通道由状态文案承担)。 +class _PetCard extends StatelessWidget { + const _PetCard({required this.pet, required this.onTap}); + + final Pet pet; + final VoidCallback onTap; @override Widget build(BuildContext context) { return Card( child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(AppRadius.xl), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), - child: Column( + padding: const EdgeInsets.all(14), + child: Row( 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), + const PetAvatar(size: PetAvatarSize.lg), + 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, + ), + ), + ], + ), + ), + if (pet.status != PetStatus.active) + TagPill( + petStatusLabel(pet.status), + color: pet.status == PetStatus.lost + ? AppColors.error + : AppColors.muted, + ) + else + const Icon(Icons.chevron_right, color: AppColors.muted), ], ), ), @@ -291,433 +217,82 @@ class _StatCard extends StatelessWidget { } } -class _TimelineTile extends StatelessWidget { - const _TimelineTile({ - required this.icon, - required this.title, - required this.subtitle, - required this.status, +/// 虚线「添加宠物」卡(05 §4.1:border 色 1.5px dashed,radius 24,高 64)。 +class _AddPetCard extends StatelessWidget { + const _AddPetCard({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _DashedBorderPainter( + color: AppColors.border, + strokeWidth: 1.5, + radius: AppRadius.xl, + ), + child: SizedBox( + height: 64, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.xl), + child: const Center( + child: Text( + '+ 添加宠物', + style: TextStyle( + color: AppColors.primaryStrong, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _DashedBorderPainter extends CustomPainter { + const _DashedBorderPainter({ + required this.color, + required this.strokeWidth, + required this.radius, }); - final IconData icon; - final String title; - final String subtitle; - final String status; + final Color color; + final double strokeWidth; + final double radius; @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: SectionCard( - child: Row( - children: [ - CircleAvatar(child: Icon(icon, color: AppColors.primary, size: 20)), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 3), - Text(subtitle, style: Theme.of(context).textTheme.bodySmall), - ], - ), - ), - TagPill(status, color: AppColors.success), - ], - ), - ), - ); - } -} - -class EditPetSheet extends StatefulWidget { - const EditPetSheet({required this.pet, super.key}); - - final PetProfile pet; - - @override - State createState() => _EditPetSheetState(); -} - -class _EditPetSheetState extends State { - late final TextEditingController nameController; - late final TextEditingController weightController; - late String breed; - late PetGender gender; - late DateTime birthday; - late String avatar; - - static const breeds = ['柴犬', '金毛寻回犬', '柯基', '哈士奇', '英国短毛猫', '其他']; - static const avatars = [ - petAvatar, - 'https://images.unsplash.com/photo-1517849845537-4d257902454a?auto=format&fit=crop&w=600&q=85', - 'https://images.unsplash.com/photo-1543466835-00a7907e9de1?auto=format&fit=crop&w=600&q=85', - ]; - - @override - void initState() { - super.initState(); - nameController = TextEditingController(text: widget.pet.name); - weightController = TextEditingController(text: '${widget.pet.weight}'); - breed = breeds.contains(widget.pet.breed) ? widget.pet.breed : '其他'; - gender = widget.pet.gender; - birthday = DateTime.tryParse(widget.pet.birthday) ?? DateTime(2024, 5, 15); - avatar = widget.pet.avatarUrl; - } - - @override - void dispose() { - nameController.dispose(); - weightController.dispose(); - super.dispose(); - } - - String dateText(DateTime value) { - return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.fromLTRB( - 20, - 10, - 20, - MediaQuery.viewInsetsOf(context).bottom + 20, - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const _SheetHandle(), - Row( - children: [ - Text('编辑宠物资料', style: Theme.of(context).textTheme.titleLarge), - const Spacer(), - IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close), - ), - ], - ), - const SizedBox(height: 14), - Center( - child: InkWell( - onTap: () { - final index = avatars.indexOf(avatar); - setState( - () => avatar = avatars[(index + 1) % avatars.length], - ); - }, - borderRadius: BorderRadius.circular(48), - child: Stack( - children: [ - RemoteImage( - url: avatar, - width: 96, - height: 96, - borderRadius: BorderRadius.circular(48), - ), - const Positioned( - right: 0, - bottom: 0, - child: CircleAvatar( - radius: 15, - child: Icon(Icons.edit, size: 15), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 18), - TextField( - controller: nameController, - decoration: const InputDecoration(labelText: '宠物昵称'), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - 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( - 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 createState() => _VaccineSheetState(); -} - -class _VaccineSheetState extends State { - late List 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.from(widget.record.items); - totalDoses = widget.record.totalDoses; - reminderController = TextEditingController( - text: widget.record.reminderVaccine, - ); - dateController = TextEditingController(text: widget.record.reminderDate); - } - - @override - void dispose() { - reminderController.dispose(); - dateController.dispose(); - super.dispose(); - } - - void changeTotal(int delta) { - final minimum = completed > items.length ? completed : items.length; - setState(() => totalDoses = (totalDoses + delta).clamp(minimum, 20)); - } - - @override - Widget build(BuildContext context) { - final progress = totalDoses == 0 ? 0.0 : completed / totalDoses; - return Padding( - padding: EdgeInsets.fromLTRB( - 20, - 10, - 20, - MediaQuery.viewInsetsOf(context).bottom + 20, - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const _SheetHandle(), - Row( - children: [ - const CircleAvatar(child: Icon(Icons.vaccines_outlined)), - const SizedBox(width: 10), - Text('疫苗接种管理', style: Theme.of(context).textTheme.titleLarge), - const Spacer(), - IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close), - ), - ], - ), - const SizedBox(height: 16), - SectionCard( - child: Column( - children: [ - Row( - children: [ - const Text('已接种 / 总规划'), - const Spacer(), - IconButton.filledTonal( - onPressed: () => changeTotal(-1), - icon: const Icon(Icons.remove), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - '$completed / $totalDoses', - style: const TextStyle(fontWeight: FontWeight.w800), - ), - ), - IconButton.filled( - onPressed: () => changeTotal(1), - icon: const Icon(Icons.add), - ), - ], - ), - const SizedBox(height: 12), - LinearProgressIndicator(value: progress, minHeight: 9), - ], - ), - ), - const SizedBox(height: 16), - Text('接种详情', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - ...items.asMap().entries.map((entry) { - final index = entry.key; - final item = entry.value; - final done = item.status == VaccineStatus.completed; - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: CheckboxListTile( - value: done, - title: Text(item.name), - subtitle: Text( - done ? '接种时间:${item.date}' : '计划接种:${item.date}', - ), - secondary: Icon( - done ? Icons.check_circle : Icons.radio_button_unchecked, - color: done ? AppColors.success : AppColors.muted, - ), - onChanged: (_) { - final today = DateTime.now(); - final date = - '${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}'; - setState(() { - items[index] = item.copyWith( - status: done - ? VaccineStatus.pending - : VaccineStatus.completed, - date: done ? '待定' : date, - ); - }); - }, - ), - ); - }), - const SizedBox(height: 10), - TextField( - controller: reminderController, - decoration: const InputDecoration(labelText: '下一针提醒'), - ), - const SizedBox(height: 12), - TextField( - controller: dateController, - decoration: const InputDecoration(labelText: '预计日期(YYYY-MM-DD)'), - ), - const SizedBox(height: 20), - FilledButton.icon( - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(50), - ), - onPressed: () => Navigator.pop( - context, - VaccineRecord( - completedDoses: completed, - totalDoses: totalDoses, - items: items, - reminderVaccine: reminderController.text.trim(), - reminderDate: dateController.text.trim(), - ), - ), - icon: const Icon(Icons.save_outlined), - label: const Text('保存疫苗记录'), - ), - ], - ), - ), - ); - } -} - -class _SheetHandle extends StatelessWidget { - const _SheetHandle(); - - @override - Widget build(BuildContext context) { - return Center( - child: Container( - width: 44, - height: 5, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: AppColors.border, - borderRadius: BorderRadius.circular(99), - ), - ), - ); + 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, + ); + distance += dashWidth + dashGap; + } + } } + + @override + bool shouldRepaint(_DashedBorderPainter oldDelegate) => + color != oldDelegate.color || + strokeWidth != oldDelegate.strokeWidth || + radius != oldDelegate.radius; } diff --git a/lib/models/models.dart b/lib/models/models.dart index 7ab8886..e81742d 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -134,85 +134,6 @@ class PetProfile { }; } -enum VaccineStatus { completed, pending } - -class VaccineItem { - const VaccineItem({ - required this.id, - required this.name, - required this.status, - required this.date, - }); - - final String id; - final String name; - final VaccineStatus status; - final String date; - - VaccineItem copyWith({VaccineStatus? status, String? date}) { - return VaccineItem( - id: id, - name: name, - status: status ?? this.status, - date: date ?? this.date, - ); - } - - factory VaccineItem.fromJson(Map 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 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 items; - final String reminderVaccine; - final String reminderDate; - - factory VaccineRecord.fromJson(Map 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)) - .toList(), - reminderVaccine: json['reminderVaccine'] as String, - reminderDate: json['reminderDate'] as String, - ); - } - - Map toJson() => { - 'completedDoses': completedDoses, - 'totalDoses': totalDoses, - 'items': items.map((item) => item.toJson()).toList(), - 'reminderVaccine': reminderVaccine, - 'reminderDate': reminderDate, - }; -} - class CommentModel { const CommentModel({ required this.id, diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 96f7468..3d4834b 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -7,12 +7,12 @@ 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 随后续工单收敛)。 PetProfile pet = initialPet; - VaccineRecord vaccines = initialVaccines; List posts = List.from(initialPosts); LocationWeather locationWeather = initialLocationWeather; bool isReady = false; @@ -21,18 +21,12 @@ 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); } - if (savedVaccines != null) { - vaccines = VaccineRecord.fromJson( - jsonDecode(savedVaccines) as Map, - ); - } if (savedPosts != null) { posts = (jsonDecode(savedPosts) as List) .map((item) => PostModel.fromJson(item as Map)) @@ -46,7 +40,6 @@ class AppState extends ChangeNotifier { } catch (error, stackTrace) { debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace'); pet = initialPet; - vaccines = initialVaccines; posts = List.from(initialPosts); locationWeather = initialLocationWeather; } finally { @@ -55,18 +48,6 @@ class AppState extends ChangeNotifier { } } - Future updatePet(PetProfile value) async { - pet = value; - notifyListeners(); - await _save(_petKey, value.toJson()); - } - - Future updateVaccines(VaccineRecord value) async { - vaccines = value; - notifyListeners(); - await _save(_vaccinesKey, value.toJson()); - } - Future updatePost(PostModel value) async { final index = posts.indexWhere((post) => post.id == value.id); if (index == -1) return; @@ -90,14 +71,12 @@ class AppState extends ChangeNotifier { Future resetDemoData() async { pet = initialPet; - vaccines = initialVaccines; posts = List.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), ]); diff --git a/test/features/pets/pet_detail_page_test.dart b/test/features/pets/pet_detail_page_test.dart new file mode 100644 index 0000000..95318dc --- /dev/null +++ b/test/features/pets/pet_detail_page_test.dart @@ -0,0 +1,179 @@ +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/pet_detail_page.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 '../../helpers/pet_test_helpers.dart'; + +void main() { + late FakePetsRepository repository; + late PetsController controller; + + setUp(() { + repository = FakePetsRepository(); + controller = PetsController(repository: repository); + }); + + Future pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: const Scaffold(body: Text('列表基底')), + ), + ); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => PetDetailPage(controller: controller, petId: petId), + ), + ), + ); + await tester.pump(); + } + + testWidgets('ready:档案字段齐备,owner 可见编辑入口', (tester) async { + repository.getPetHandler = (petId) async => buildPet( + 'p-1', + overrides: { + 'microchipNo': '985112000000001', + 'personality': '活泼', + 'sterilizedOn': '2025-06-01', + }, + ); + + await pumpDetail(tester); + await tester.pumpAndSettle(); + + expect(find.text('豆豆'), findsOneWidget); + expect(find.text('基本资料'), findsOneWidget); + expect(find.text('柴犬'), findsOneWidget); + expect(find.text('男孩'), findsOneWidget); + expect(find.text('2024-03-15'), findsOneWidget); + expect(find.text('985112000000001'), findsOneWidget); + expect(find.text('活泼'), findsOneWidget); + expect(find.text('2025-06-01'), findsOneWidget); + expect(find.byIcon(Icons.edit_outlined), findsWidgets); + expect(find.text('编辑资料'), findsOneWidget); + }); + + testWidgets('生日估算标记与未填写字段兜底', (tester) async { + repository.getPetHandler = (petId) async => + buildPet('p-1', overrides: {'birthDateEstimated': true}); + + await pumpDetail(tester); + await tester.pumpAndSettle(); + + expect(find.text('2024-03-15(估算)'), findsOneWidget); + // 芯片号 / 性格 / 绝育日期未填。 + expect(find.text('未填写'), findsNWidgets(3)); + }); + + testWidgets('四态 · loading:无内存副本时居中转圈', (tester) async { + final completer = Completer(); + repository.getPetHandler = (petId) => completer.future; + + await pumpDetail(tester); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete(buildPet('p-1')); + await tester.pumpAndSettle(); + expect(find.text('豆豆'), findsOneWidget); + }); + + testWidgets('四态 · error/retry:无副本加载失败给横幅与重试', (tester) async { + var calls = 0; + repository.getPetHandler = (petId) async { + calls++; + if (calls == 1) throw const ApiNetworkException('断网'); + return buildPet('p-1'); + }; + + await pumpDetail(tester); + await tester.pumpAndSettle(); + + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(find.text('豆豆'), findsOneWidget); + }); + + testWidgets('四态 · notFound(40401):提示不存在,返回列表并触发刷新', (tester) async { + repository.getPetHandler = (petId) async => + throw const PetNotFoundException(message: '不存在'); + var listCalls = 0; + repository.listPetsHandler = () async { + listCalls++; + return const []; + }; + + await pumpDetail(tester); + await tester.pumpAndSettle(); + + expect(find.text('宠物不存在或已被删除'), findsOneWidget); + + await tester.tap(find.text('返回列表')); + await tester.pumpAndSettle(); + + expect(find.byType(PetDetailPage), findsNothing); + expect(find.text('列表基底'), findsOneWidget); + expect(listCalls, 1); + }); + + testWidgets('有内存副本时先渲染副本;后台刷新失败降级 SnackBar', (tester) async { + repository.listPetsHandler = () async => [buildPet('p-1')]; + await controller.refresh(); + final completer = Completer(); + repository.getPetHandler = (petId) => completer.future; + + await pumpDetail(tester); + await tester.pump(); + + // 副本即时渲染,无整页 loading。 + expect(find.text('豆豆'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + + completer.completeError(const ApiNetworkException('断网')); + await tester.pumpAndSettle(); + + expect(find.text('豆豆'), findsOneWidget); + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + }); + + testWidgets('viewer 角色隐藏全部编辑入口(40300 语义)', (tester) async { + repository.getPetHandler = (petId) async => + buildPet('p-1', overrides: {'myRole': 'viewer'}); + + await pumpDetail(tester); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.edit_outlined), findsNothing); + expect(find.text('编辑资料'), findsNothing); + expect(find.byIcon(Icons.edit), findsNothing); + }); + + testWidgets('owner 点编辑 → 进入编辑表单并预填', (tester) async { + repository.getPetHandler = (petId) async => buildPet('p-1'); + + await pumpDetail(tester); + await tester.pumpAndSettle(); + + await tester.tap(find.text('编辑资料')); + await tester.pumpAndSettle(); + + expect(find.byType(PetFormPage), findsOneWidget); + expect(find.text('编辑宠物资料'), findsOneWidget); + expect(find.text('豆豆'), findsOneWidget); + }); +} diff --git a/test/features/pets/pet_display_test.dart b/test/features/pets/pet_display_test.dart new file mode 100644 index 0000000..947e0e4 --- /dev/null +++ b/test/features/pets/pet_display_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/pets/pet_display.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; + +import '../../helpers/pet_test_helpers.dart'; + +void main() { + test('petAgeLabel:岁 / 月 / 未满月 / 未知边界', () { + final now = DateTime(2026, 9, 8); + expect(petAgeLabel(DateTime(2024, 3, 15), now: now), '2 岁'); + expect(petAgeLabel(DateTime(2025, 9, 9), now: now), '11 个月'); + expect(petAgeLabel(DateTime(2026, 3, 1), now: now), '6 个月'); + expect(petAgeLabel(DateTime(2026, 9, 1), now: now), '未满月'); + expect(petAgeLabel(null, now: now), '年龄未知'); + expect(petAgeLabel(DateTime(2027), now: now), '年龄未知'); + }); + + test('petMetaLine:品种 · 性别 · 年龄;自定义品种回退', () { + final now = DateTime(2026, 9, 8); + expect(petMetaLine(buildPet('p-1'), now: now), '柴犬 · 男孩 · 2 岁'); + + final custom = buildPet( + 'p-2', + overrides: { + 'breedId': null, + 'breedDisplayName': null, + 'customBreedName': '狸花', + 'sex': 'unknown', + 'birthDate': null, + }, + ); + expect(petMetaLine(custom, now: now), '狸花 · 性别未知 · 年龄未知'); + }); + + test('petLoadErrorMessage:网络 / 限流 / 其他三档文案', () { + expect( + petLoadErrorMessage(const ApiNetworkException('x')), + '网络异常,请检查网络后重试', + ); + expect(petLoadErrorMessage(const ApiRateLimitException()), '请求过于频繁,请稍后再试'); + expect( + petLoadErrorMessage( + const ApiBusinessException(code: 40000, message: 'x'), + ), + '加载失败,请稍后重试', + ); + expect(petLoadErrorMessage(null), '加载失败,请稍后重试'); + }); + + test('物种 / 性别 / 状态标签', () { + expect(petSpeciesLabel(PetSpecies.dog), '狗狗'); + expect(petSpeciesLabel(PetSpecies.cat), '猫咪'); + expect(petSexLabel(PetSex.female), '女孩'); + expect(petStatusLabel(PetStatus.archived), '已归档'); + expect(petStatusLabel(PetStatus.lost), '走失中'); + }); +} diff --git a/test/features/pets/pet_form_page_test.dart b/test/features/pets/pet_form_page_test.dart new file mode 100644 index 0000000..3d46c07 --- /dev/null +++ b/test/features/pets/pet_form_page_test.dart @@ -0,0 +1,345 @@ +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/pet_analytics.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 '../../helpers/pet_test_helpers.dart'; + +void main() { + late FakePetsRepository repository; + late PetsController controller; + late List<(String, Map?)> events; + late PetAnalytics analytics; + + setUp(() { + repository = FakePetsRepository(); + controller = PetsController(repository: repository); + events = []; + analytics = PetAnalytics( + (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, Widget form) async { + tester.view.physicalSize = const Size(700, 2000); + 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: (_) => form))); + await tester.pumpAndSettle(); + } + + Future pumpCreate( + WidgetTester tester, { + PetCreateEntryPoint entryPoint = PetCreateEntryPoint.profileEmptyState, + }) { + return pumpForm( + tester, + PetFormPage.create( + controller: controller, + analytics: analytics, + entryPoint: entryPoint, + ), + ); + } + + Future selectBreed(WidgetTester tester, String label) async { + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text(label).last); + await tester.pumpAndSettle(); + } + + group('创建模式', () { + testWidgets('提交空表单:昵称/性别/品种三处校验拦截 + failed(validation_error)', ( + tester, + ) async { + await pumpCreate(tester); + + await tester.tap(find.text('保存档案')); + await tester.pumpAndSettle(); + + expect(find.text('请输入宠物昵称'), findsOneWidget); + expect(find.text('请选择性别'), findsOneWidget); + expect(find.text('请选择品种'), findsOneWidget); + + final failed = eventsOf('pet_create_failed'); + expect(failed, hasLength(1)); + expect(failed.single!['failureReason'], 'validation_error'); + expect(failed.single!['attemptSeq'], 1); + }); + + testWidgets('首次输入触发 pet_create_started(每次进入仅一次)', (tester) async { + await pumpCreate(tester); + + expect(eventsOf('pet_create_started'), isEmpty); + + await tester.enterText(find.widgetWithText(TextFormField, '宠物昵称'), '咪'); + await tester.tap(find.text('女孩')); + await tester.pumpAndSettle(); + + final started = eventsOf('pet_create_started'); + expect(started, hasLength(1)); + expect(started.single!['entryPoint'], 'profile_empty_state'); + }); + + testWidgets('目录品种建档成功:请求对齐契约、页面回列表、succeeded 三属性', (tester) async { + CreatePetRequest? captured; + repository.createPetHandler = (request) async { + captured = request; + return buildPet('p-new', name: '咪咪'); + }; + + await pumpCreate(tester); + await tester.enterText(find.widgetWithText(TextFormField, '宠物昵称'), '咪咪'); + await tester.tap(find.text('女孩')); + await tester.pumpAndSettle(); + await selectBreed(tester, '柴犬'); + await tester.enterText( + find.widgetWithText(TextFormField, '芯片号(可选)'), + '985112000000001', + ); + + await tester.tap(find.text('保存档案')); + await tester.pumpAndSettle(); + + expect(captured!.toJson(), { + 'name': '咪咪', + 'species': 'dog', + 'sex': 'female', + 'breedId': 'b-1', + 'microchipNo': '985112000000001', + }); + expect(find.text('列表基底'), findsOneWidget); + expect(controller.pets.single.id, 'p-new'); + + final succeeded = eventsOf('pet_create_succeeded'); + expect(succeeded, hasLength(1)); + expect(succeeded.single!['species'], 'dog'); + expect(succeeded.single!['petIndex'], 1); + expect(succeeded.single!['durationMs'], isA()); + }); + + testWidgets('自定义品种与目录互斥:只发送 customBreedName', (tester) async { + CreatePetRequest? captured; + repository.createPetHandler = (request) async { + captured = request; + return buildPet('p-new'); + }; + + await pumpCreate(tester); + await tester.enterText(find.widgetWithText(TextFormField, '宠物昵称'), '大黄'); + await tester.tap(find.text('男孩')); + await tester.pumpAndSettle(); + await selectBreed(tester, '自定义品种…'); + + expect(find.text('品种名称'), findsOneWidget); + await tester.enterText( + find.widgetWithText(TextFormField, '品种名称'), + '中华田园犬', + ); + + await tester.tap(find.text('保存档案')); + await tester.pumpAndSettle(); + + expect(captured!.toJson()['customBreedName'], '中华田园犬'); + expect(captured!.toJson().containsKey('breedId'), isFalse); + }); + + testWidgets('40903 芯片号冲突:字段级报错 + failed 带 errorCode/httpStatus', ( + tester, + ) async { + repository.createPetHandler = (request) async => + throw const MicrochipTakenException(message: '已登记'); + + await pumpCreate(tester); + await tester.enterText(find.widgetWithText(TextFormField, '宠物昵称'), '咪咪'); + await tester.tap(find.text('女孩')); + await tester.pumpAndSettle(); + await selectBreed(tester, '柴犬'); + + await tester.tap(find.text('保存档案')); + await tester.pumpAndSettle(); + + expect(find.text('该芯片号已被登记,请核对后重试'), findsOneWidget); + expect(find.byType(PetFormPage), findsOneWidget); + + final failed = eventsOf('pet_create_failed'); + expect(failed.single!['errorCode'], 40903); + expect(failed.single!['httpStatus'], 409); + expect(failed.single!['failureReason'], 'validation_error'); + }); + + testWidgets('网络异常:SnackBar + 重试动作 + failed(network_error)', (tester) async { + repository.createPetHandler = (request) async => + throw const ApiNetworkException('断网'); + + await pumpCreate(tester); + await tester.enterText(find.widgetWithText(TextFormField, '宠物昵称'), '咪咪'); + await tester.tap(find.text('未知')); + await tester.pumpAndSettle(); + await selectBreed(tester, '柴犬'); + + await tester.tap(find.text('保存档案')); + await tester.pumpAndSettle(); + + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + expect( + eventsOf('pet_create_failed').single!['failureReason'], + 'network_error', + ); + }); + + testWidgets('品种目录加载失败:回落自定义输入,重试后目录恢复', (tester) async { + var calls = 0; + repository.listBreedsHandler = (species) async { + calls++; + if (calls == 1) throw const ApiNetworkException('断网'); + return [Breed.fromJson(sampleBreedJson())]; + }; + + await pumpCreate(tester); + + expect(find.text('品种目录加载失败,可先填写自定义品种'), findsOneWidget); + expect(find.text('品种名称'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(find.byType(DropdownButtonFormField), findsOneWidget); + expect(find.text('品种目录加载失败,可先填写自定义品种'), findsNothing); + }); + + testWidgets('昵称失焦校验:空值即时 errorText,输入即清除', (tester) async { + await pumpCreate(tester); + + await tester.tap(find.widgetWithText(TextFormField, '宠物昵称')); + await tester.pump(); + // 焦点移到芯片号 → 昵称失焦触发校验。 + await tester.tap(find.widgetWithText(TextFormField, '芯片号(可选)')); + await tester.pumpAndSettle(); + + expect(find.text('请输入宠物昵称'), findsOneWidget); + + await tester.enterText(find.widgetWithText(TextFormField, '宠物昵称'), '豆'); + await tester.pumpAndSettle(); + expect(find.text('请输入宠物昵称'), findsNothing); + }); + }); + + group('编辑模式', () { + testWidgets('预填 + 物种锁定 + 差量提交(只发送改动字段与 version)', (tester) async { + UpdatePetRequest? captured; + repository.updatePetHandler = (petId, request) async { + captured = request; + return buildPet('p-1', name: '豆豆二世', version: 4); + }; + + await pumpForm( + tester, + PetFormPage.edit( + controller: controller, + pet: buildPet('p-1', version: 3), + analytics: analytics, + ), + ); + + // 物种锁定为静态文案,无可选分段控件。 + expect(find.text('物种(创建后不可修改)'), findsOneWidget); + expect(find.byType(SegmentedButton), findsNothing); + expect(find.text('编辑宠物资料'), findsOneWidget); + + await tester.enterText( + find.widgetWithText(TextFormField, '宠物昵称'), + '豆豆二世', + ); + await tester.tap(find.text('保存修改')); + await tester.pumpAndSettle(); + + expect(captured!.toJson(), {'version': 3, 'name': '豆豆二世'}); + expect(find.text('列表基底'), findsOneWidget); + // 编辑不产生 pet_create_* 事件(06 §1.4)。 + expect(events, isEmpty); + }); + + testWidgets('40902 版本冲突:明确提示 + 自动取新 version,重提成功', (tester) async { + var updateCalls = 0; + final versions = []; + repository.updatePetHandler = (petId, request) async { + updateCalls++; + versions.add(request.version); + if (updateCalls == 1) { + throw const PetVersionConflictException(message: '版本过期'); + } + return buildPet('p-1', name: '豆豆二世', version: 5); + }; + repository.getPetHandler = (petId) async => buildPet('p-1', version: 4); + + await pumpForm( + tester, + PetFormPage.edit( + controller: controller, + pet: buildPet('p-1', version: 3), + ), + ); + await tester.enterText( + find.widgetWithText(TextFormField, '宠物昵称'), + '豆豆二世', + ); + + await tester.tap(find.text('保存修改')); + await tester.pumpAndSettle(); + + // 明确的用户提示 + 刷新路径(已拉取最新版本)。 + expect(find.text('资料已在其他设备被修改,已获取最新版本,请核对后重新保存'), findsOneWidget); + expect(find.byType(PetFormPage), findsOneWidget); + + await tester.tap(find.text('保存修改')); + await tester.pumpAndSettle(); + + expect(versions, [3, 4]); + expect(find.text('列表基底'), findsOneWidget); + }); + + testWidgets('无任何变更:不发 PATCH 直接返回', (tester) async { + var updateCalls = 0; + repository.updatePetHandler = (petId, request) async { + updateCalls++; + return buildPet('p-1'); + }; + + await pumpForm( + tester, + PetFormPage.edit( + controller: controller, + pet: buildPet('p-1', version: 3), + ), + ); + + await tester.tap(find.text('保存修改')); + await tester.pumpAndSettle(); + + expect(updateCalls, 0); + expect(find.text('列表基底'), findsOneWidget); + }); + }); +} diff --git a/test/features/pets/pets_controller_test.dart b/test/features/pets/pets_controller_test.dart index 39af165..f0b3e31 100644 --- a/test/features/pets/pets_controller_test.dart +++ b/test/features/pets/pets_controller_test.dart @@ -3,40 +3,9 @@ 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:patbond_flutter/features/pets/pets_controller.dart'; -import 'package:patbond_flutter/features/pets/pets_repository.dart'; import '../../helpers/pet_test_helpers.dart'; -/// 假仓库:各方法可注入行为,未注入的方法抛 UnimplementedError。 -class FakePetsRepository implements PetsRepository { - Future> Function()? listPetsHandler; - Future Function(CreatePetRequest)? createPetHandler; - Future Function(String)? getPetHandler; - Future Function(String, UpdatePetRequest)? updatePetHandler; - - @override - Future> listPets() => - listPetsHandler?.call() ?? Future.value(const []); - - @override - Future createPet(CreatePetRequest request) => createPetHandler!(request); - - @override - Future getPet(String petId) => getPetHandler!(petId); - - @override - Future updatePet(String petId, UpdatePetRequest request) => - updatePetHandler!(petId, request); - - @override - dynamic noSuchMethod(Invocation invocation) => - throw UnimplementedError('${invocation.memberName}'); -} - -Pet pet(String id, {String name = '豆豆', int version = 1}) => Pet.fromJson( - samplePetJson(overrides: {'id': id, 'name': name, 'version': version}), -); - void main() { late FakePetsRepository repository; late PetsController controller; @@ -53,7 +22,7 @@ void main() { }); test('refresh 成功:loading → ready,列表就位', () async { - repository.listPetsHandler = () async => [pet('p-1'), pet('p-2')]; + repository.listPetsHandler = () async => [buildPet('p-1'), buildPet('p-2')]; final phases = []; controller.addListener(() => phases.add(controller.phase)); @@ -83,7 +52,7 @@ void main() { expect(controller.phase, PetsLoadPhase.error); expect(controller.lastError, isA()); - repository.listPetsHandler = () async => [pet('p-1')]; + repository.listPetsHandler = () async => [buildPet('p-1')]; await controller.refresh(); expect(controller.phase, PetsLoadPhase.ready); @@ -92,9 +61,10 @@ void main() { }); test('createPet:成功后插入列表头', () async { - repository.listPetsHandler = () async => [pet('p-1')]; + repository.listPetsHandler = () async => [buildPet('p-1')]; await controller.refresh(); - repository.createPetHandler = (request) async => pet('p-2', name: '咪咪'); + repository.createPetHandler = (request) async => + buildPet('p-2', name: '咪咪'); final created = await controller.createPet( const CreatePetRequest( @@ -110,7 +80,7 @@ void main() { }); test('createPet 失败(40903):类型化异常外抛,列表不变', () async { - repository.listPetsHandler = () async => [pet('p-1')]; + repository.listPetsHandler = () async => [buildPet('p-1')]; await controller.refresh(); repository.createPetHandler = (request) async => throw const MicrochipTakenException(message: '芯片号已被登记'); @@ -130,10 +100,10 @@ void main() { }); test('updatePet:成功后同步列表内存副本', () async { - repository.listPetsHandler = () async => [pet('p-1', version: 1)]; + repository.listPetsHandler = () async => [buildPet('p-1', version: 1)]; await controller.refresh(); repository.updatePetHandler = (petId, request) async => - pet('p-1', name: '豆豆二世', version: 2); + buildPet('p-1', name: '豆豆二世', version: 2); await controller.updatePet( 'p-1', @@ -155,13 +125,66 @@ void main() { }); test('getPet:详情结果回写列表副本', () async { - repository.listPetsHandler = () async => [pet('p-1', version: 1)]; + repository.listPetsHandler = () async => [buildPet('p-1', version: 1)]; await controller.refresh(); - repository.getPetHandler = (petId) async => pet('p-1', version: 5); + repository.getPetHandler = (petId) async => buildPet('p-1', version: 5); final detail = await controller.getPet('p-1'); expect(detail.version, 5); expect(controller.pets.single.version, 5); }); + + test('loadBreeds:按物种缓存,仓库只打一次', () async { + var calls = 0; + repository.listBreedsHandler = (species) async { + calls++; + return [Breed.fromJson(sampleBreedJson())]; + }; + + final first = await controller.loadBreeds(PetSpecies.dog); + final second = await controller.loadBreeds(PetSpecies.dog); + + expect(first.single.displayName, '柴犬'); + expect(identical(first, second), isTrue); + expect(calls, 1); + + await controller.loadBreeds(PetSpecies.cat); + expect(calls, 2); + }); + + test('loadBreeds 失败:异常外抛且不缓存,重试可恢复', () async { + var calls = 0; + repository.listBreedsHandler = (species) async { + calls++; + if (calls == 1) throw const ApiNetworkException('断网'); + return [Breed.fromJson(sampleBreedJson())]; + }; + + await expectLater( + controller.loadBreeds(PetSpecies.dog), + throwsA(isA()), + ); + final breeds = await controller.loadBreeds(PetSpecies.dog); + expect(breeds, hasLength(1)); + }); + + test('reset:登出清空回 initial(含品种缓存)', () async { + repository.listPetsHandler = () async => [buildPet('p-1')]; + var breedCalls = 0; + repository.listBreedsHandler = (species) async { + breedCalls++; + return [Breed.fromJson(sampleBreedJson())]; + }; + await controller.refresh(); + await controller.loadBreeds(PetSpecies.dog); + + controller.reset(); + + expect(controller.phase, PetsLoadPhase.initial); + expect(controller.pets, isEmpty); + expect(controller.lastError, isNull); + await controller.loadBreeds(PetSpecies.dog); + expect(breedCalls, 2); + }); } diff --git a/test/features/pets/pets_page_test.dart b/test/features/pets/pets_page_test.dart new file mode 100644 index 0000000..b33b2d6 --- /dev/null +++ b/test/features/pets/pets_page_test.dart @@ -0,0 +1,147 @@ +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/pet_detail_page.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/pets_page.dart'; + +import '../../helpers/pet_test_helpers.dart'; + +class _RecordingNavObserver extends NavigatorObserver { + final pushedNames = []; + + @override + void didPush(Route route, Route? previousRoute) { + pushedNames.add(route.settings.name); + } +} + +void main() { + late FakePetsRepository repository; + late PetsController controller; + late _RecordingNavObserver observer; + + setUp(() { + repository = FakePetsRepository(); + controller = PetsController(repository: repository); + observer = _RecordingNavObserver(); + }); + + Future pumpPage(WidgetTester tester) { + return tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + navigatorObservers: [observer], + home: Scaffold(body: PetsPage(controller: controller)), + ), + ); + } + + testWidgets('四态 · loading:拉取期间居中转圈', (tester) async { + final completer = Completer>(); + repository.listPetsHandler = () => completer.future; + + await pumpPage(tester); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete(const []); + await tester.pumpAndSettle(); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('四态 · empty:空态插画 + 建档 CTA 打开 pet_form 表单页', (tester) async { + await pumpPage(tester); + await tester.pumpAndSettle(); + + expect(find.byType(EmptyStateIllustration), findsOneWidget); + expect(find.text('还没有宠物档案'), findsOneWidget); + + await tester.tap(find.text('添加宠物')); + await tester.pumpAndSettle(); + + expect(find.byType(PetFormPage), findsOneWidget); + // page_viewed(pet_form) 接线依据:路由名进入既有 RouteObserver 采集。 + expect(observer.pushedNames.last, 'pet_form'); + }); + + testWidgets('四态 · error/retry:横幅 + 重试按钮,重试后恢复列表', (tester) async { + var calls = 0; + repository.listPetsHandler = () async { + calls++; + if (calls == 1) throw const ApiNetworkException('断网'); + return [buildPet('p-1')]; + }; + + await pumpPage(tester); + await tester.pumpAndSettle(); + + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(find.text('豆豆'), findsOneWidget); + }); + + testWidgets('四态 · ready:列表卡渲染名字与元信息,含添加入口', (tester) async { + repository.listPetsHandler = () async => [ + buildPet('p-1'), + buildPet( + 'p-2', + name: '咪咪', + overrides: { + 'species': 'cat', + 'sex': 'female', + 'breedId': null, + 'breedDisplayName': null, + 'customBreedName': '狸花', + }, + ), + ]; + + await pumpPage(tester); + await tester.pumpAndSettle(); + + expect(find.text('我的宠物'), findsOneWidget); + expect(find.text('豆豆'), findsOneWidget); + expect(find.text('咪咪'), findsOneWidget); + expect(find.textContaining('柴犬 · 男孩'), findsOneWidget); + expect(find.textContaining('狸花 · 女孩'), findsOneWidget); + expect(find.text('+ 添加宠物'), findsOneWidget); + expect(find.text('添加'), findsOneWidget); + }); + + testWidgets('点宠物卡 → 以 pet_detail 路由名进入详情页', (tester) async { + repository.listPetsHandler = () async => [buildPet('p-1')]; + repository.getPetHandler = (petId) async => buildPet('p-1'); + + await pumpPage(tester); + await tester.pumpAndSettle(); + + await tester.tap(find.text('豆豆')); + await tester.pumpAndSettle(); + + expect(find.byType(PetDetailPage), findsOneWidget); + expect(observer.pushedNames.last, 'pet_detail'); + }); + + testWidgets('非 active 宠物显示状态标签(走失中)', (tester) async { + repository.listPetsHandler = () async => [ + buildPet('p-1', overrides: {'status': 'lost'}), + ]; + + await pumpPage(tester); + await tester.pumpAndSettle(); + + expect(find.text('走失中'), findsOneWidget); + }); +} diff --git a/test/helpers/pet_test_helpers.dart b/test/helpers/pet_test_helpers.dart index d1e702d..ebf61ed 100644 --- a/test/helpers/pet_test_helpers.dart +++ b/test/helpers/pet_test_helpers.dart @@ -1,6 +1,10 @@ -/// pets 域测试样本(契约 openapi.yaml v1.2.0 各 schema 全字段 JSON)。 +/// pets 域测试样本(契约 openapi.yaml v1.2.0 各 schema 全字段 JSON) +/// 与共享假仓库。 library; +import 'package:patbond_flutter/features/pets/pet_models.dart'; +import 'package:patbond_flutter/features/pets/pets_repository.dart'; + Map okListEnvelope(List data) => { 'code': 0, 'message': 'ok', @@ -105,3 +109,55 @@ Map samplePetSummaryJson() => { 'amountCents': 12850, }, }; + +Map sampleBreedJson({ + String id = 'b-1', + String species = 'dog', + String code = 'shiba', + String displayName = '柴犬', +}) => {'id': id, 'species': species, 'code': code, 'displayName': displayName}; + +/// 快速构造 Pet(widget/controller 测试共用)。 +Pet buildPet( + String id, { + String name = '豆豆', + int version = 1, + Map overrides = const {}, +}) => Pet.fromJson( + samplePetJson( + overrides: {'id': id, 'name': name, 'version': version, ...overrides}, + ), +); + +/// 假仓库:各方法可注入行为;listPets 默认空列表,其余未注入的方法抛 +/// UnimplementedError(22 号报告 §7:widget 测试注入假仓库的共享实现)。 +class FakePetsRepository implements PetsRepository { + Future> Function()? listPetsHandler; + Future Function(CreatePetRequest)? createPetHandler; + Future Function(String)? getPetHandler; + Future Function(String, UpdatePetRequest)? updatePetHandler; + Future> Function(PetSpecies?)? listBreedsHandler; + + @override + Future> listPets() => + listPetsHandler?.call() ?? Future.value(const []); + + @override + Future createPet(CreatePetRequest request) => createPetHandler!(request); + + @override + Future getPet(String petId) => getPetHandler!(petId); + + @override + Future updatePet(String petId, UpdatePetRequest request) => + updatePetHandler!(petId, request); + + @override + Future> listBreeds({PetSpecies? species}) => + listBreedsHandler?.call(species) ?? + Future.value([Breed.fromJson(sampleBreedJson())]); + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName}'); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 86ae8fe..2c513b3 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -4,6 +4,7 @@ import 'package:patbond_flutter/features/auth/session_manager.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'helpers/auth_test_helpers.dart'; +import 'helpers/pet_test_helpers.dart'; void main() { testWidgets('Patbond renders the main navigation', (tester) async { @@ -12,7 +13,11 @@ void main() { ..markAuthenticated(); await tester.pumpWidget( - App(sessionManager: session, authRepository: FakeAuthRepository()), + App( + sessionManager: session, + authRepository: FakeAuthRepository(), + petsRepository: FakePetsRepository(), + ), ); await tester.pumpAndSettle();