- 档案 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 <noreply@anthropic.com>
This commit is contained in:
+47
-2
@@ -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/session_manager.dart';
|
||||||
import 'package:patbond_flutter/features/auth/splash_page.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/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';
|
import 'package:patbond_flutter/state/app_state.dart';
|
||||||
|
|
||||||
class App extends StatefulWidget {
|
class App extends StatefulWidget {
|
||||||
const App({super.key, this.sessionManager, this.authRepository});
|
const App({
|
||||||
|
super.key,
|
||||||
|
this.sessionManager,
|
||||||
|
this.authRepository,
|
||||||
|
this.petsRepository,
|
||||||
|
});
|
||||||
|
|
||||||
/// 测试注入口;生产默认走安全存储 + 真实 API。
|
/// 测试注入口;生产默认走安全存储 + 真实 API。
|
||||||
final SessionManager? sessionManager;
|
final SessionManager? sessionManager;
|
||||||
final AuthRepository? authRepository;
|
final AuthRepository? authRepository;
|
||||||
|
final PetsRepository? petsRepository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<App> createState() => _AppState();
|
State<App> createState() => _AppState();
|
||||||
@@ -30,10 +39,13 @@ class _AppState extends State<App> {
|
|||||||
late final AppState appState;
|
late final AppState appState;
|
||||||
late final SessionManager sessionManager;
|
late final SessionManager sessionManager;
|
||||||
late final AuthRepository authRepository;
|
late final AuthRepository authRepository;
|
||||||
|
late final PetsController petsController;
|
||||||
|
late final PetAnalytics petAnalytics;
|
||||||
late final SessionTracker _sessionTracker;
|
late final SessionTracker _sessionTracker;
|
||||||
late final AnalyticsService _analytics;
|
late final AnalyticsService _analytics;
|
||||||
late final PageViewTracker _pageViewTracker;
|
late final PageViewTracker _pageViewTracker;
|
||||||
late final AnalyticsRouteObserver _routeObserver;
|
late final AnalyticsRouteObserver _routeObserver;
|
||||||
|
TokenRefresher? _sharedRefresher;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -64,6 +76,10 @@ class _AppState extends State<App> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
authRepository = widget.authRepository ?? _buildRepository();
|
authRepository = widget.authRepository ?? _buildRepository();
|
||||||
|
petsController = PetsController(
|
||||||
|
repository: widget.petsRepository ?? _buildPetsRepository(),
|
||||||
|
);
|
||||||
|
petAnalytics = PetAnalytics(_analytics.trackEvent);
|
||||||
|
|
||||||
// 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件)
|
// 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件)
|
||||||
sessionManager.addListener(_reportAuthStateChange);
|
sessionManager.addListener(_reportAuthStateChange);
|
||||||
@@ -79,9 +95,18 @@ class _AppState extends State<App> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 三服务分端口直连(ADR-002 无网关),token 刷新单飞经共享
|
||||||
|
/// [TokenRefresher](22 号报告 §7 交接约定)。
|
||||||
|
TokenRefresher _ensureRefresher() {
|
||||||
|
return _sharedRefresher ??= TokenRefresher(
|
||||||
|
dio: buildPatbondDio(session: sessionManager),
|
||||||
|
session: sessionManager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
AuthRepository _buildRepository() {
|
AuthRepository _buildRepository() {
|
||||||
final dio = buildPatbondDio(session: sessionManager);
|
final dio = buildPatbondDio(session: sessionManager);
|
||||||
final refresher = TokenRefresher(dio: dio, session: sessionManager);
|
final refresher = _ensureRefresher();
|
||||||
final api = ApiClient(
|
final api = ApiClient(
|
||||||
dio: dio,
|
dio: dio,
|
||||||
session: sessionManager,
|
session: sessionManager,
|
||||||
@@ -95,6 +120,19 @@ class _AppState extends State<App> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PetsRepository _buildPetsRepository() {
|
||||||
|
final dio = buildPatbondDio(
|
||||||
|
session: sessionManager,
|
||||||
|
baseUrl: patbondPetApiBaseUrl,
|
||||||
|
);
|
||||||
|
final api = ApiClient(
|
||||||
|
dio: dio,
|
||||||
|
session: sessionManager,
|
||||||
|
refresher: _ensureRefresher(),
|
||||||
|
);
|
||||||
|
return ApiPetsRepository(api: api);
|
||||||
|
}
|
||||||
|
|
||||||
AnalyticsPageName? _resolveRootPage() {
|
AnalyticsPageName? _resolveRootPage() {
|
||||||
// 回栈到无名根路由时解析当前认证状态页/主壳 Tab
|
// 回栈到无名根路由时解析当前认证状态页/主壳 Tab
|
||||||
return switch (sessionManager.status) {
|
return switch (sessionManager.status) {
|
||||||
@@ -105,6 +143,10 @@ class _AppState extends State<App> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _reportAuthStateChange() {
|
void _reportAuthStateChange() {
|
||||||
|
// 登出即清宠物档案内存副本(跨账号不泄漏;重登后列表页重新拉取)。
|
||||||
|
if (sessionManager.status == AuthStatus.unauthenticated) {
|
||||||
|
petsController.reset();
|
||||||
|
}
|
||||||
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
|
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
|
||||||
final page = switch (sessionManager.status) {
|
final page = switch (sessionManager.status) {
|
||||||
AuthStatus.unauthenticated => AnalyticsPageName.login,
|
AuthStatus.unauthenticated => AnalyticsPageName.login,
|
||||||
@@ -119,6 +161,7 @@ class _AppState extends State<App> {
|
|||||||
sessionManager.removeListener(_reportAuthStateChange);
|
sessionManager.removeListener(_reportAuthStateChange);
|
||||||
WidgetsBinding.instance.removeObserver(_sessionTracker);
|
WidgetsBinding.instance.removeObserver(_sessionTracker);
|
||||||
appState.dispose();
|
appState.dispose();
|
||||||
|
petsController.dispose();
|
||||||
if (widget.sessionManager == null) sessionManager.dispose();
|
if (widget.sessionManager == null) sessionManager.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -140,6 +183,8 @@ class _AppState extends State<App> {
|
|||||||
return MainShellPage(
|
return MainShellPage(
|
||||||
key: const ValueKey('shell'),
|
key: const ValueKey('shell'),
|
||||||
appState: appState,
|
appState: appState,
|
||||||
|
petsController: petsController,
|
||||||
|
petAnalytics: petAnalytics,
|
||||||
pageViewTracker: _pageViewTracker,
|
pageViewTracker: _pageViewTracker,
|
||||||
onLogout: authRepository.logout,
|
onLogout: authRepository.logout,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,33 +82,6 @@ const initialPet = PetProfile(
|
|||||||
avatarUrl: petAvatar,
|
avatarUrl: petAvatar,
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialVaccines = VaccineRecord(
|
|
||||||
completedDoses: 2,
|
|
||||||
totalDoses: 3,
|
|
||||||
items: [
|
|
||||||
VaccineItem(
|
|
||||||
id: 'v1',
|
|
||||||
name: '犬五联 第1针',
|
|
||||||
status: VaccineStatus.completed,
|
|
||||||
date: '2025-05-12',
|
|
||||||
),
|
|
||||||
VaccineItem(
|
|
||||||
id: 'v2',
|
|
||||||
name: '狂犬疫苗',
|
|
||||||
status: VaccineStatus.completed,
|
|
||||||
date: '2025-06-15',
|
|
||||||
),
|
|
||||||
VaccineItem(
|
|
||||||
id: 'v3',
|
|
||||||
name: '犬五联 加强针',
|
|
||||||
status: VaccineStatus.pending,
|
|
||||||
date: '待定',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
reminderVaccine: '犬五联 加强针',
|
|
||||||
reminderDate: '2026-08-15',
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialPosts = [
|
const initialPosts = [
|
||||||
PostModel(
|
PostModel(
|
||||||
id: 'post1',
|
id: 'post1',
|
||||||
|
|||||||
@@ -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/core/theme/app_theme.dart';
|
||||||
import 'package:patbond_flutter/features/create/create_page.dart';
|
import 'package:patbond_flutter/features/create/create_page.dart';
|
||||||
import 'package:patbond_flutter/features/home/home_page.dart';
|
import 'package:patbond_flutter/features/home/home_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_controller.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pets_page.dart';
|
import 'package:patbond_flutter/features/pets/pets_page.dart';
|
||||||
import 'package:patbond_flutter/features/post/post_detail_page.dart';
|
import 'package:patbond_flutter/features/post/post_detail_page.dart';
|
||||||
import 'package:patbond_flutter/features/profile/profile_page.dart';
|
import 'package:patbond_flutter/features/profile/profile_page.dart';
|
||||||
@@ -15,13 +17,21 @@ import 'package:patbond_flutter/widgets/common.dart';
|
|||||||
class MainShellPage extends StatefulWidget {
|
class MainShellPage extends StatefulWidget {
|
||||||
const MainShellPage({
|
const MainShellPage({
|
||||||
required this.appState,
|
required this.appState,
|
||||||
|
required this.petsController,
|
||||||
super.key,
|
super.key,
|
||||||
|
this.petAnalytics,
|
||||||
this.pageViewTracker,
|
this.pageViewTracker,
|
||||||
this.onLogout,
|
this.onLogout,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppState appState;
|
final AppState appState;
|
||||||
|
|
||||||
|
/// 宠物档案状态(T2-11 拆出的独立 pets feature;档案 Tab 数据源)。
|
||||||
|
final PetsController petsController;
|
||||||
|
|
||||||
|
/// pet 域埋点强类型封装(建宠漏斗三事件)。
|
||||||
|
final PetAnalytics? petAnalytics;
|
||||||
|
|
||||||
/// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。
|
/// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。
|
||||||
final PageViewTracker? pageViewTracker;
|
final PageViewTracker? pageViewTracker;
|
||||||
|
|
||||||
@@ -38,11 +48,12 @@ class _MainShellPageState extends State<MainShellPage> {
|
|||||||
|
|
||||||
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
|
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 = [
|
static const _tabPages = [
|
||||||
AnalyticsPageName.home,
|
AnalyticsPageName.home,
|
||||||
AnalyticsPageName.create,
|
AnalyticsPageName.create,
|
||||||
AnalyticsPageName.petArchive,
|
AnalyticsPageName.petList,
|
||||||
AnalyticsPageName.services,
|
AnalyticsPageName.services,
|
||||||
AnalyticsPageName.profile,
|
AnalyticsPageName.profile,
|
||||||
];
|
];
|
||||||
@@ -104,7 +115,10 @@ class _MainShellPageState extends State<MainShellPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
PetsPage(appState: widget.appState),
|
PetsPage(
|
||||||
|
controller: widget.petsController,
|
||||||
|
analytics: widget.petAnalytics,
|
||||||
|
),
|
||||||
ServicesPage(
|
ServicesPage(
|
||||||
showPersonal: showPersonalServices,
|
showPersonal: showPersonalServices,
|
||||||
locationWeather: widget.appState.locationWeather,
|
locationWeather: widget.appState.locationWeather,
|
||||||
|
|||||||
@@ -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<PetDetailPage> createState() => _PetDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PetDetailPageState extends State<PetDetailPage> {
|
||||||
|
_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<void> _load() async {
|
||||||
|
if (_pet == null) {
|
||||||
|
setState(() => _phase = _DetailPhase.loading);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final pet = await widget.controller.getPet(widget.petId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pet = pet;
|
||||||
|
_phase = _DetailPhase.ready;
|
||||||
|
});
|
||||||
|
} on PetNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _phase = _DetailPhase.notFound);
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_pet == null) {
|
||||||
|
setState(() {
|
||||||
|
_error = error;
|
||||||
|
_phase = _DetailPhase.error;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 已有内存副本:刷新失败降级为瞬态提示,不打断阅读。
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEdit() async {
|
||||||
|
final pet = _pet;
|
||||||
|
if (pet == null) return;
|
||||||
|
final updated = await Navigator.of(context).push<Pet>(
|
||||||
|
// 编辑态不带路由名:pet_form 专属建宠漏斗到达段(06 §1.6),
|
||||||
|
// 编辑曝光不计入,避免「到达→动笔」分母虚高。
|
||||||
|
fadePageRoute(
|
||||||
|
PetFormPage.edit(
|
||||||
|
controller: widget.controller,
|
||||||
|
pet: pet,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (updated != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_pet = updated;
|
||||||
|
_phase = _DetailPhase.ready;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已保存修改')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅 owner 可改档案(40300:caregiver/viewer 改档案被拒 → 隐藏写入口)。
|
||||||
|
bool get _canEdit =>
|
||||||
|
_phase == _DetailPhase.ready && _pet?.myRole == PetRole.owner;
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/// 宠物档案展示文案的纯函数集合(列表 / 详情 / 表单共用,可单测)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
|
String petSpeciesLabel(PetSpecies species) => switch (species) {
|
||||||
|
PetSpecies.dog => '狗狗',
|
||||||
|
PetSpecies.cat => '猫咪',
|
||||||
|
PetSpecies.other => '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
String petSexLabel(PetSex sex) => switch (sex) {
|
||||||
|
PetSex.male => '男孩',
|
||||||
|
PetSex.female => '女孩',
|
||||||
|
PetSex.unknown => '性别未知',
|
||||||
|
};
|
||||||
|
|
||||||
|
String petStatusLabel(PetStatus status) => switch (status) {
|
||||||
|
PetStatus.active => '正常',
|
||||||
|
PetStatus.lost => '走失中',
|
||||||
|
PetStatus.deceased => '已离世',
|
||||||
|
PetStatus.archived => '已归档',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 品种展示:目录品种名 > 自定义品种名 > 未知(Pet 响应恰有其一非空,
|
||||||
|
/// 双空为兜底)。
|
||||||
|
String petBreedLabel(Pet pet) =>
|
||||||
|
pet.breedDisplayName ?? pet.customBreedName ?? '品种未知';
|
||||||
|
|
||||||
|
/// 年龄:满一岁按「N 岁」、不满一岁按「N 个月」、当月新生「未满月」;
|
||||||
|
/// 生日缺席或晚于今天为「年龄未知」。
|
||||||
|
String petAgeLabel(DateTime? birthDate, {DateTime? now}) {
|
||||||
|
if (birthDate == null) return '年龄未知';
|
||||||
|
final today = now ?? DateTime.now();
|
||||||
|
var months =
|
||||||
|
(today.year - birthDate.year) * 12 + today.month - birthDate.month;
|
||||||
|
if (today.day < birthDate.day) months--;
|
||||||
|
if (months < 0) return '年龄未知';
|
||||||
|
if (months == 0) return '未满月';
|
||||||
|
if (months < 12) return '$months 个月';
|
||||||
|
return '${months ~/ 12} 岁';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表卡 / 详情头的元信息行:「柴犬 · 男孩 · 2 岁」。
|
||||||
|
String petMetaLine(Pet pet, {DateTime? now}) =>
|
||||||
|
'${petBreedLabel(pet)} · ${petSexLabel(pet.sex)} · '
|
||||||
|
'${petAgeLabel(pet.birthDate, now: now)}';
|
||||||
|
|
||||||
|
/// 加载失败的用户文案(一迭代三层错误模型:不可归属字段的加载错误
|
||||||
|
/// 走横幅;服务端原始 message 不上屏)。
|
||||||
|
String petLoadErrorMessage(ApiException? error) => switch (error) {
|
||||||
|
ApiNetworkException _ => '网络异常,请检查网络后重试',
|
||||||
|
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
|
||||||
|
_ => '加载失败,请稍后重试',
|
||||||
|
};
|
||||||
@@ -0,0 +1,714 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_controller.dart';
|
||||||
|
|
||||||
|
/// 品种下拉里「自定义品种」哨兵值(与目录 breedId 互斥)。
|
||||||
|
const _customBreedSentinel = '__custom__';
|
||||||
|
|
||||||
|
/// 建宠 / 编辑宠物资料表单页(T2-12)。
|
||||||
|
///
|
||||||
|
/// - 字段对齐冻结契约:昵称*、物种*(创建后不可改)、性别*(契约必填)、
|
||||||
|
/// 品种(目录选择与自定义互斥、整体替换)、生日(+是否估算)、
|
||||||
|
/// 芯片号、性格;头像按 ADR-010 本地占位、不做上传。
|
||||||
|
/// - 错误分层沿用登录纵切:失焦+提交双校验走 errorText(onChanged 即清)、
|
||||||
|
/// 不可归属错误走 InlineErrorBanner、网络瞬态走 SnackBar+重试。
|
||||||
|
/// - 409/40902 版本冲突:提示「已被修改」并自动拉取最新版本(保留用户
|
||||||
|
/// 输入、更新乐观锁 version),用户核对后重新保存;40903 芯片号冲突
|
||||||
|
/// 为字段级报错。
|
||||||
|
/// - 埋点(仅创建模式):首次输入触发 pet_create_started;成功/失败
|
||||||
|
/// 经 [PetAnalytics] 强类型上报。表单页路由名 pet_form 由调用方在
|
||||||
|
/// push 时给定(创建态),page_viewed 走既有 RouteObserver。
|
||||||
|
class PetFormPage extends StatefulWidget {
|
||||||
|
const PetFormPage.create({
|
||||||
|
required this.controller,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.entryPoint = PetCreateEntryPoint.petList,
|
||||||
|
}) : pet = null;
|
||||||
|
|
||||||
|
const PetFormPage.edit({
|
||||||
|
required this.controller,
|
||||||
|
required Pet this.pet,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
}) : entryPoint = null;
|
||||||
|
|
||||||
|
final PetsController controller;
|
||||||
|
|
||||||
|
/// null 为创建模式。
|
||||||
|
final Pet? pet;
|
||||||
|
|
||||||
|
final PetAnalytics? analytics;
|
||||||
|
|
||||||
|
/// 创建模式的入口(pet_create_started.entryPoint)。
|
||||||
|
final PetCreateEntryPoint? entryPoint;
|
||||||
|
|
||||||
|
bool get isCreate => pet == null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PetFormPage> createState() => _PetFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PetFormPageState extends State<PetFormPage> {
|
||||||
|
final _nameCtrl = TextEditingController();
|
||||||
|
final _customBreedCtrl = TextEditingController();
|
||||||
|
final _microchipCtrl = TextEditingController();
|
||||||
|
final _personalityCtrl = TextEditingController();
|
||||||
|
|
||||||
|
PetSpecies _species = PetSpecies.dog;
|
||||||
|
PetSex? _sex;
|
||||||
|
DateTime? _birthDate;
|
||||||
|
bool _birthDateEstimated = false;
|
||||||
|
|
||||||
|
/// 目录 breedId 或 [_customBreedSentinel];null 未选择。
|
||||||
|
String? _breedChoice;
|
||||||
|
List<Breed>? _breeds;
|
||||||
|
bool _breedsLoading = false;
|
||||||
|
bool _breedsFailed = false;
|
||||||
|
|
||||||
|
String? _nameError;
|
||||||
|
String? _sexError;
|
||||||
|
String? _breedError;
|
||||||
|
String? _microchipError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
/// 编辑基线:40902 冲突刷新后更新(version 与差量计算的比较基准)。
|
||||||
|
Pet? _basePet;
|
||||||
|
|
||||||
|
bool _startedFired = false;
|
||||||
|
int _attemptSeq = 0;
|
||||||
|
late final DateTime _openedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_openedAt = DateTime.now();
|
||||||
|
final pet = widget.pet;
|
||||||
|
_basePet = pet;
|
||||||
|
if (pet != null) {
|
||||||
|
_nameCtrl.text = pet.name;
|
||||||
|
_species = pet.species;
|
||||||
|
_sex = pet.sex;
|
||||||
|
_birthDate = pet.birthDate;
|
||||||
|
_birthDateEstimated = pet.birthDateEstimated;
|
||||||
|
_microchipCtrl.text = pet.microchipNo ?? '';
|
||||||
|
_personalityCtrl.text = pet.personality ?? '';
|
||||||
|
if (pet.customBreedName != null) {
|
||||||
|
_breedChoice = _customBreedSentinel;
|
||||||
|
_customBreedCtrl.text = pet.customBreedName!;
|
||||||
|
} else {
|
||||||
|
_breedChoice = pet.breedId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_loadBreeds();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
_customBreedCtrl.dispose();
|
||||||
|
_microchipCtrl.dispose();
|
||||||
|
_personalityCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 品种目录(网络字典:加载 / 失败重试 / 空目录自定义兜底)----
|
||||||
|
|
||||||
|
Future<void> _loadBreeds() async {
|
||||||
|
setState(() {
|
||||||
|
_breedsLoading = true;
|
||||||
|
_breedsFailed = false;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final breeds = await widget.controller.loadBreeds(_species);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_breeds = breeds;
|
||||||
|
_breedsLoading = false;
|
||||||
|
});
|
||||||
|
} on ApiException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_breedsLoading = false;
|
||||||
|
_breedsFailed = true;
|
||||||
|
// 目录不可用不阻塞建档:回落到自定义品种输入。
|
||||||
|
_breedChoice ??= _customBreedSentinel;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 埋点(仅创建模式)----
|
||||||
|
|
||||||
|
void _markStarted() {
|
||||||
|
if (!widget.isCreate || _startedFired) return;
|
||||||
|
_startedFired = true;
|
||||||
|
widget.analytics?.createStarted(entryPoint: widget.entryPoint!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackCreateFailed(PetCreateFailureReason reason, [int? errorCode]) {
|
||||||
|
if (!widget.isCreate) return;
|
||||||
|
widget.analytics?.createFailed(
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: _attemptSeq,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 校验(失焦 + 提交双校验,onChanged 即清)----
|
||||||
|
|
||||||
|
String? _validateName() => _nameCtrl.text.trim().isEmpty ? '请输入宠物昵称' : null;
|
||||||
|
|
||||||
|
String? _validateSex() => _sex == null ? '请选择性别' : null;
|
||||||
|
|
||||||
|
String? _validateBreed() {
|
||||||
|
if (_breedChoice == null) return '请选择品种';
|
||||||
|
if (_breedChoice == _customBreedSentinel &&
|
||||||
|
_customBreedCtrl.text.trim().isEmpty) {
|
||||||
|
return '请输入品种名称';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearError(void Function() clear) {
|
||||||
|
setState(() {
|
||||||
|
clear();
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 提交 ----
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
_attemptSeq++;
|
||||||
|
final nameError = _validateName();
|
||||||
|
final sexError = _validateSex();
|
||||||
|
final breedError = _validateBreed();
|
||||||
|
if (nameError != null || sexError != null || breedError != null) {
|
||||||
|
setState(() {
|
||||||
|
_nameError = nameError;
|
||||||
|
_sexError = sexError;
|
||||||
|
_breedError = breedError;
|
||||||
|
});
|
||||||
|
_trackCreateFailed(PetCreateFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (widget.isCreate) {
|
||||||
|
await _create();
|
||||||
|
} else {
|
||||||
|
await _update();
|
||||||
|
}
|
||||||
|
} on MicrochipTakenException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _microchipError = '该芯片号已被登记,请核对后重试');
|
||||||
|
_trackCreateFailed(PetCreateFailureReason.validationError, 40903);
|
||||||
|
} on PetVersionConflictException {
|
||||||
|
await _handleVersionConflict();
|
||||||
|
} on PetNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 编辑目标已不存在(防枚举三态同响应):返回列表并刷新。
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
|
||||||
|
widget.controller.refresh();
|
||||||
|
navigator.pop();
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('你没有权限修改该宠物的资料');
|
||||||
|
} on ApiRateLimitException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('操作过于频繁,请稍后再试');
|
||||||
|
_trackCreateFailed(PetCreateFailureReason.rateLimited);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError(
|
||||||
|
error.code == ApiCodes.paramError ? '请检查填写内容后重试' : '保存失败,请稍后重试',
|
||||||
|
);
|
||||||
|
_trackCreateFailed(
|
||||||
|
error.code == ApiCodes.paramError
|
||||||
|
? PetCreateFailureReason.validationError
|
||||||
|
: PetCreateFailureReason.serverError,
|
||||||
|
error.code,
|
||||||
|
);
|
||||||
|
} on ApiNetworkException {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('网络异常,请检查网络后重试'),
|
||||||
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_trackCreateFailed(PetCreateFailureReason.networkError);
|
||||||
|
} on SessionExpiredException {
|
||||||
|
// 会话失效:认证状态机自动回登录页,表单不再提示。
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isCustomBreed => _breedChoice == _customBreedSentinel;
|
||||||
|
|
||||||
|
Future<void> _create() async {
|
||||||
|
final request = CreatePetRequest(
|
||||||
|
name: _nameCtrl.text.trim(),
|
||||||
|
species: _species,
|
||||||
|
sex: _sex!,
|
||||||
|
breedId: _isCustomBreed ? null : _breedChoice,
|
||||||
|
customBreedName: _isCustomBreed ? _customBreedCtrl.text.trim() : null,
|
||||||
|
birthDate: _birthDate,
|
||||||
|
birthDateEstimated: _birthDate != null ? _birthDateEstimated : null,
|
||||||
|
microchipNo: _textOrNull(_microchipCtrl),
|
||||||
|
personality: _textOrNull(_personalityCtrl),
|
||||||
|
);
|
||||||
|
// petIndex:该用户第几只宠物(H2 假设数据源)。
|
||||||
|
final petIndex = widget.controller.pets.length + 1;
|
||||||
|
final pet = await widget.controller.createPet(request);
|
||||||
|
widget.analytics?.createSucceeded(
|
||||||
|
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
|
||||||
|
species: _species,
|
||||||
|
petIndex: petIndex,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(pet);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _update() async {
|
||||||
|
final request = _buildUpdateRequest();
|
||||||
|
if (request == null) {
|
||||||
|
// 无变更:直接返回,不发空 PATCH。
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final pet = await widget.controller.updatePet(_basePet!.id, request);
|
||||||
|
if (mounted) Navigator.of(context).pop(pet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 差量构造部分更新请求(缺席字段不发;契约不支持清空回 null,
|
||||||
|
/// 清空的输入视为未变更)。全部未变更返回 null。
|
||||||
|
UpdatePetRequest? _buildUpdateRequest() {
|
||||||
|
final base = _basePet!;
|
||||||
|
final name = _nameCtrl.text.trim();
|
||||||
|
|
||||||
|
final String? breedId = _isCustomBreed ? null : _breedChoice;
|
||||||
|
final String? customName = _isCustomBreed
|
||||||
|
? _customBreedCtrl.text.trim()
|
||||||
|
: null;
|
||||||
|
final breedChanged =
|
||||||
|
breedId != base.breedId || customName != base.customBreedName;
|
||||||
|
|
||||||
|
final microchip = _textOrNull(_microchipCtrl);
|
||||||
|
final personality = _textOrNull(_personalityCtrl);
|
||||||
|
final birthChanged =
|
||||||
|
_birthDate != null && !_sameDate(_birthDate, base.birthDate);
|
||||||
|
final estimatedChanged =
|
||||||
|
_birthDate != null && _birthDateEstimated != base.birthDateEstimated;
|
||||||
|
|
||||||
|
final request = UpdatePetRequest(
|
||||||
|
version: base.version,
|
||||||
|
name: name != base.name ? name : null,
|
||||||
|
sex: _sex != base.sex ? _sex : null,
|
||||||
|
// 品种对整体替换:任一半变化则整对发送。
|
||||||
|
breedId: breedChanged ? breedId : null,
|
||||||
|
customBreedName: breedChanged ? customName : null,
|
||||||
|
birthDate: birthChanged ? _birthDate : null,
|
||||||
|
birthDateEstimated: estimatedChanged ? _birthDateEstimated : null,
|
||||||
|
microchipNo: microchip != null && microchip != base.microchipNo
|
||||||
|
? microchip
|
||||||
|
: null,
|
||||||
|
personality: personality != null && personality != base.personality
|
||||||
|
? personality
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
// 只剩 version 一个键 → 无实际变更。
|
||||||
|
return request.toJson().length == 1 ? null : request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40902:提示 + 刷新路径——拉取最新版本更新乐观锁基线,
|
||||||
|
/// 保留用户输入,由用户核对后重新保存(T2-11 §7 定型路径)。
|
||||||
|
Future<void> _handleVersionConflict() async {
|
||||||
|
try {
|
||||||
|
final fresh = await widget.controller.getPet(_basePet!.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _basePet = fresh);
|
||||||
|
_showFormError('资料已在其他设备被修改,已获取最新版本,请核对后重新保存');
|
||||||
|
} on ApiException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('资料已在其他设备被修改,请返回后刷新重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _textOrNull(TextEditingController controller) {
|
||||||
|
final text = controller.text.trim();
|
||||||
|
return text.isEmpty ? null : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _sameDate(DateTime? a, DateTime? b) {
|
||||||
|
if (a == null || b == null) return a == b;
|
||||||
|
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UI ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: AppColors.ink,
|
||||||
|
title: Text(widget.isCreate ? '添加宠物' : '编辑宠物资料'),
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: AppColors.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 30),
|
||||||
|
children: [
|
||||||
|
// 头像本地占位:M2 不做上传(ADR-010 / D2-1)。
|
||||||
|
const Center(child: PetAvatar(size: PetAvatarSize.xl)),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Focus(
|
||||||
|
onFocusChange: (hasFocus) {
|
||||||
|
if (!hasFocus && mounted) {
|
||||||
|
setState(() => _nameError = _validateName());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: AppTextField(
|
||||||
|
label: '宠物昵称',
|
||||||
|
controller: _nameCtrl,
|
||||||
|
prefixIcon: Icons.pets_outlined,
|
||||||
|
errorText: _nameError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_nameError != null || _formError != null) {
|
||||||
|
_clearError(() => _nameError = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_FieldLabel(widget.isCreate ? '物种' : '物种(创建后不可修改)'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (widget.isCreate)
|
||||||
|
SegmentedButton<PetSpecies>(
|
||||||
|
segments: [
|
||||||
|
for (final species in PetSpecies.values)
|
||||||
|
ButtonSegment(
|
||||||
|
value: species,
|
||||||
|
label: Text(petSpeciesLabel(species)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_species},
|
||||||
|
onSelectionChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_species = value.first;
|
||||||
|
// 物种切换:品种目录随物种重载,已选品种作废。
|
||||||
|
_breedChoice = null;
|
||||||
|
_breedError = null;
|
||||||
|
_breeds = null;
|
||||||
|
});
|
||||||
|
_loadBreeds();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
petSpeciesLabel(_species),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
const _FieldLabel('性别'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<PetSex>(
|
||||||
|
emptySelectionAllowed: true,
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: PetSex.male,
|
||||||
|
icon: Icon(Icons.male),
|
||||||
|
label: Text('男孩'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: PetSex.female,
|
||||||
|
icon: Icon(Icons.female),
|
||||||
|
label: Text('女孩'),
|
||||||
|
),
|
||||||
|
ButtonSegment(value: PetSex.unknown, label: Text('未知')),
|
||||||
|
],
|
||||||
|
selected: {?_sex},
|
||||||
|
onSelectionChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_sex = value.isEmpty ? null : value.first;
|
||||||
|
_sexError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_sexError != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_FieldError(_sexError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
const _FieldLabel('品种'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._breedSection(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_dateTile(),
|
||||||
|
if (_birthDate != null)
|
||||||
|
CheckboxListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
title: const Text('生日为估算日期', style: TextStyle(fontSize: 13)),
|
||||||
|
value: _birthDateEstimated,
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() => _birthDateEstimated = value ?? false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppTextField(
|
||||||
|
label: '芯片号(可选)',
|
||||||
|
controller: _microchipCtrl,
|
||||||
|
prefixIcon: Icons.qr_code_2_outlined,
|
||||||
|
errorText: _microchipError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_microchipError != null || _formError != null) {
|
||||||
|
_clearError(() => _microchipError = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppTextField(
|
||||||
|
label: '性格(可选,如:活泼)',
|
||||||
|
controller: _personalityCtrl,
|
||||||
|
prefixIcon: Icons.emoji_emotions_outlined,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (_) => _markStarted(),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: widget.isCreate ? '保存档案' : '保存修改',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _breedSection() {
|
||||||
|
if (_breedsLoading) {
|
||||||
|
return const [
|
||||||
|
SizedBox(
|
||||||
|
height: 52,
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
if (_breedsFailed) {
|
||||||
|
widgets
|
||||||
|
..add(
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'品种目录加载失败,可先填写自定义品种',
|
||||||
|
style: TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: _loadBreeds, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(const SizedBox(height: 8));
|
||||||
|
} else {
|
||||||
|
final breeds = _breeds ?? const <Breed>[];
|
||||||
|
final knownIds = breeds.map((breed) => breed.id).toSet();
|
||||||
|
final base = _basePet;
|
||||||
|
widgets
|
||||||
|
..add(
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _breedChoice,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '品种',
|
||||||
|
errorText: _isCustomBreed ? null : _breedError,
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
// 编辑时目录中缺席的既有品种保底成项,避免下拉值失配。
|
||||||
|
if (base?.breedId != null && !knownIds.contains(base!.breedId))
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: base.breedId,
|
||||||
|
child: Text(base.breedDisplayName ?? '当前品种'),
|
||||||
|
),
|
||||||
|
for (final breed in breeds)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: breed.id,
|
||||||
|
child: Text(breed.displayName),
|
||||||
|
),
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: _customBreedSentinel,
|
||||||
|
child: Text('自定义品种…'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_breedChoice = value;
|
||||||
|
_breedError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(const SizedBox(height: 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isCustomBreed) {
|
||||||
|
widgets.add(
|
||||||
|
Focus(
|
||||||
|
onFocusChange: (hasFocus) {
|
||||||
|
if (!hasFocus && mounted) {
|
||||||
|
setState(() => _breedError = _validateBreed());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: AppTextField(
|
||||||
|
label: '品种名称',
|
||||||
|
controller: _customBreedCtrl,
|
||||||
|
prefixIcon: Icons.edit_note_outlined,
|
||||||
|
errorText: _breedError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_breedError != null || _formError != null) {
|
||||||
|
_clearError(() => _breedError = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (_breedError != null && _breedsFailed) {
|
||||||
|
widgets.add(_FieldError(_breedError!));
|
||||||
|
}
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dateTile() {
|
||||||
|
return ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
),
|
||||||
|
tileColor: AppColors.surface,
|
||||||
|
leading: const Icon(Icons.cake_outlined, color: AppColors.muted),
|
||||||
|
title: const Text('生日(可选)', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
_birthDate == null ? '未填写' : dateToJson(_birthDate!),
|
||||||
|
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.calendar_month_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
enabled: !_submitting,
|
||||||
|
onTap: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final value = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _birthDate ?? DateTime(now.year - 1, now.month),
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: now,
|
||||||
|
);
|
||||||
|
if (value != null && mounted) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() => _birthDate = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FieldLabel extends StatelessWidget {
|
||||||
|
const _FieldLabel(this.text);
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FieldError extends StatelessWidget {
|
||||||
|
const _FieldError(this.text);
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ class PetsController extends ChangeNotifier {
|
|||||||
List<Pet> _pets = const [];
|
List<Pet> _pets = const [];
|
||||||
ApiException? _lastError;
|
ApiException? _lastError;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
final Map<PetSpecies, List<Breed>> _breedsCache = {};
|
||||||
|
|
||||||
PetsLoadPhase get phase => _phase;
|
PetsLoadPhase get phase => _phase;
|
||||||
|
|
||||||
@@ -84,6 +85,26 @@ class PetsController extends ChangeNotifier {
|
|||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 品种目录(只读字典):按物种缓存,会话内目录不变;
|
||||||
|
/// 失败按类型化异常外抛,供表单层内联重试。
|
||||||
|
Future<List<Breed>> loadBreeds(PetSpecies species) async {
|
||||||
|
final cached = _breedsCache[species];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
final breeds = await _repository.listBreeds(species: species);
|
||||||
|
_breedsCache[species] = breeds;
|
||||||
|
return breeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登出清空:回 initial 态并清缓存,避免上一账号档案跨会话泄漏;
|
||||||
|
/// 重新登录后主壳重建,列表页 initState 重新触发 [refresh]。
|
||||||
|
void reset() {
|
||||||
|
_phase = PetsLoadPhase.initial;
|
||||||
|
_pets = const [];
|
||||||
|
_lastError = null;
|
||||||
|
_breedsCache.clear();
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
void _notify() {
|
void _notify() {
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-652
@@ -1,723 +1,298 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||||
|
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
import 'package:patbond_flutter/data/demo_data.dart';
|
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||||
import 'package:patbond_flutter/models/models.dart';
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||||
import 'package:patbond_flutter/state/app_state.dart';
|
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
import 'package:patbond_flutter/widgets/common.dart';
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
class PetsPage extends StatelessWidget {
|
/// 档案 Tab 落地页:宠物列表(T2-12 / 05 号规范 §4.1 P1)。
|
||||||
const PetsPage({required this.appState, super.key});
|
///
|
||||||
|
/// 真实数据经 [PetsController] 四态驱动:loading(居中转圈)、
|
||||||
|
/// empty(空态插画 + 建档 CTA)、error(横幅 + 重试)、ready(列表)。
|
||||||
|
/// 页面不直连 ApiClient,不读写 AppState demo 数据。
|
||||||
|
class PetsPage extends StatefulWidget {
|
||||||
|
const PetsPage({required this.controller, super.key, this.analytics});
|
||||||
|
|
||||||
final AppState appState;
|
final PetsController controller;
|
||||||
|
final PetAnalytics? analytics;
|
||||||
|
|
||||||
String ageLabel(String value) {
|
@override
|
||||||
final birthday = DateTime.tryParse(value);
|
State<PetsPage> createState() => _PetsPageState();
|
||||||
if (birthday == null) return '年龄未知';
|
|
||||||
final today = DateTime.now();
|
|
||||||
var age = today.year - birthday.year;
|
|
||||||
if (today.month < birthday.month ||
|
|
||||||
(today.month == birthday.month && today.day < birthday.day)) {
|
|
||||||
age--;
|
|
||||||
}
|
|
||||||
return '${age < 0 ? 0 : age} 岁';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> editPet(BuildContext context) async {
|
class _PetsPageState extends State<PetsPage> {
|
||||||
final value = await showModalBottomSheet<PetProfile>(
|
@override
|
||||||
context: context,
|
void initState() {
|
||||||
isScrollControlled: true,
|
super.initState();
|
||||||
useSafeArea: true,
|
// 主壳挂载即预取(IndexedStack 各 Tab 同时构建);重登后控制器
|
||||||
builder: (context) => EditPetSheet(pet: appState.pet),
|
// 已被 reset 回 initial,会重新拉取。
|
||||||
|
if (widget.controller.phase == PetsLoadPhase.initial) {
|
||||||
|
widget.controller.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCreate(PetCreateEntryPoint entryPoint) async {
|
||||||
|
final created = await Navigator.of(context).push<Pet>(
|
||||||
|
fadePageRoute(
|
||||||
|
PetFormPage.create(
|
||||||
|
controller: widget.controller,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
entryPoint: entryPoint,
|
||||||
|
),
|
||||||
|
// 建宠表单曝光由既有 RouteObserver 采集(06 §1.6 pet_form)。
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.petForm.pageName),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (value != null) await appState.updatePet(value);
|
if (created != null && mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('已为「${created.name}」建立档案 🐾')));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> editVaccines(BuildContext context) async {
|
void _openDetail(Pet pet) {
|
||||||
final value = await showModalBottomSheet<VaccineRecord>(
|
Navigator.of(context).push(
|
||||||
context: context,
|
fadePageRoute(
|
||||||
isScrollControlled: true,
|
PetDetailPage(
|
||||||
useSafeArea: true,
|
controller: widget.controller,
|
||||||
builder: (context) => VaccineSheet(record: appState.vaccines),
|
petId: pet.id,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (value != null) await appState.updateVaccines(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pet = appState.pet;
|
return ListenableBuilder(
|
||||||
final vaccines = appState.vaccines;
|
listenable: widget.controller,
|
||||||
final progress = vaccines.totalDoses == 0
|
builder: (context, _) {
|
||||||
? 0.0
|
final controller = widget.controller;
|
||||||
: vaccines.completedDoses / vaccines.totalDoses;
|
switch (controller.phase) {
|
||||||
|
case PetsLoadPhase.initial:
|
||||||
|
case PetsLoadPhase.loading:
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
case PetsLoadPhase.error:
|
||||||
|
return _LoadErrorView(
|
||||||
|
message: petLoadErrorMessage(controller.lastError),
|
||||||
|
onRetry: controller.refresh,
|
||||||
|
);
|
||||||
|
case PetsLoadPhase.ready:
|
||||||
|
if (controller.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: EmptyStateIllustration(
|
||||||
|
icon: Icons.pets,
|
||||||
|
title: '还没有宠物档案',
|
||||||
|
description: '添加毛孩子,开始记录 TA 的健康点滴',
|
||||||
|
ctaLabel: '添加宠物',
|
||||||
|
onCtaPressed: () =>
|
||||||
|
_openCreate(PetCreateEntryPoint.profileEmptyState),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _petList(controller.pets);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return ListView(
|
Widget _petList(List<Pet> pets) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: widget.controller.refresh,
|
||||||
|
child: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Stack(
|
Text('我的宠物', style: Theme.of(context).textTheme.titleLarge),
|
||||||
children: [
|
const Spacer(),
|
||||||
InkWell(
|
TextButton.icon(
|
||||||
onTap: () => editPet(context),
|
onPressed: () => _openCreate(PetCreateEntryPoint.petList),
|
||||||
borderRadius: BorderRadius.circular(54),
|
icon: const Icon(Icons.add, size: 18),
|
||||||
child: RemoteImage(
|
label: const Text('添加'),
|
||||||
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),
|
const SizedBox(height: 12),
|
||||||
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
|
for (final pet in pets) ...[
|
||||||
const SizedBox(height: 4),
|
_PetCard(pet: pet, onTap: () => _openDetail(pet)),
|
||||||
Text(
|
|
||||||
'${pet.breed} · ${pet.gender == PetGender.male ? '男孩' : '女孩'} · ${ageLabel(pet.birthday)} · 活泼',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
OutlinedButton.icon(
|
],
|
||||||
onPressed: () => editPet(context),
|
_AddPetCard(onTap: () => _openCreate(PetCreateEntryPoint.petList)),
|
||||||
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: '已记录',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatCard extends StatelessWidget {
|
/// 加载失败态:InlineErrorBanner + 重试按钮(05 §4.2 错误三层模型)。
|
||||||
const _StatCard({
|
class _LoadErrorView extends StatelessWidget {
|
||||||
required this.icon,
|
const _LoadErrorView({required this.message, required this.onRetry});
|
||||||
required this.color,
|
|
||||||
required this.value,
|
|
||||||
required this.label,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
final String message;
|
||||||
final Color color;
|
final Future<void> Function() onRetry;
|
||||||
final String value;
|
|
||||||
final String label;
|
@override
|
||||||
final VoidCallback? onTap;
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
InlineErrorBanner(message: message),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(onPressed: onRetry, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宠物卡(05 §4.1):头像 lg + 名字 + 元信息 + chevron;非 active
|
||||||
|
/// 状态以 TagPill 标示(图文双通道由状态文案承担)。
|
||||||
|
class _PetCard extends StatelessWidget {
|
||||||
|
const _PetCard({required this.pet, required this.onTap});
|
||||||
|
|
||||||
|
final Pet pet;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
padding: const EdgeInsets.all(14),
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: color),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w800)),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TimelineTile extends StatelessWidget {
|
|
||||||
const _TimelineTile({
|
|
||||||
required this.icon,
|
|
||||||
required this.title,
|
|
||||||
required this.subtitle,
|
|
||||||
required this.status,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final String subtitle;
|
|
||||||
final String status;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
|
||||||
child: SectionCard(
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(child: Icon(icon, color: AppColors.primary, size: 20)),
|
const PetAvatar(size: PetAvatarSize.lg),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
pet.name,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 4),
|
||||||
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
|
Text(
|
||||||
],
|
petMetaLine(pet),
|
||||||
),
|
style: const TextStyle(
|
||||||
),
|
color: AppColors.inkSoft,
|
||||||
TagPill(status, color: AppColors.success),
|
fontSize: 12,
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class EditPetSheet extends StatefulWidget {
|
|
||||||
const EditPetSheet({required this.pet, super.key});
|
|
||||||
|
|
||||||
final PetProfile pet;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<EditPetSheet> createState() => _EditPetSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EditPetSheetState extends State<EditPetSheet> {
|
|
||||||
late final TextEditingController nameController;
|
|
||||||
late final TextEditingController weightController;
|
|
||||||
late String breed;
|
|
||||||
late PetGender gender;
|
|
||||||
late DateTime birthday;
|
|
||||||
late String avatar;
|
|
||||||
|
|
||||||
static const breeds = ['柴犬', '金毛寻回犬', '柯基', '哈士奇', '英国短毛猫', '其他'];
|
|
||||||
static const avatars = [
|
|
||||||
petAvatar,
|
|
||||||
'https://images.unsplash.com/photo-1517849845537-4d257902454a?auto=format&fit=crop&w=600&q=85',
|
|
||||||
'https://images.unsplash.com/photo-1543466835-00a7907e9de1?auto=format&fit=crop&w=600&q=85',
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
nameController = TextEditingController(text: widget.pet.name);
|
|
||||||
weightController = TextEditingController(text: '${widget.pet.weight}');
|
|
||||||
breed = breeds.contains(widget.pet.breed) ? widget.pet.breed : '其他';
|
|
||||||
gender = widget.pet.gender;
|
|
||||||
birthday = DateTime.tryParse(widget.pet.birthday) ?? DateTime(2024, 5, 15);
|
|
||||||
avatar = widget.pet.avatarUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
nameController.dispose();
|
|
||||||
weightController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
String dateText(DateTime value) {
|
|
||||||
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
20,
|
|
||||||
10,
|
|
||||||
20,
|
|
||||||
MediaQuery.viewInsetsOf(context).bottom + 20,
|
|
||||||
),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const _SheetHandle(),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text('编辑宠物资料', style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
Center(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
final index = avatars.indexOf(avatar);
|
|
||||||
setState(
|
|
||||||
() => avatar = avatars[(index + 1) % avatars.length],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(48),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
RemoteImage(
|
|
||||||
url: avatar,
|
|
||||||
width: 96,
|
|
||||||
height: 96,
|
|
||||||
borderRadius: BorderRadius.circular(48),
|
|
||||||
),
|
|
||||||
const Positioned(
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 15,
|
|
||||||
child: Icon(Icons.edit, size: 15),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (pet.status != PetStatus.active)
|
||||||
const SizedBox(height: 18),
|
TagPill(
|
||||||
TextField(
|
petStatusLabel(pet.status),
|
||||||
controller: nameController,
|
color: pet.status == PetStatus.lost
|
||||||
decoration: const InputDecoration(labelText: '宠物昵称'),
|
? AppColors.error
|
||||||
),
|
: AppColors.muted,
|
||||||
const SizedBox(height: 12),
|
|
||||||
DropdownButtonFormField<String>(
|
|
||||||
initialValue: breed,
|
|
||||||
decoration: const InputDecoration(labelText: '品种'),
|
|
||||||
items: breeds
|
|
||||||
.map(
|
|
||||||
(value) =>
|
|
||||||
DropdownMenuItem(value: value, child: Text(value)),
|
|
||||||
)
|
)
|
||||||
.toList(),
|
else
|
||||||
onChanged: (value) {
|
const Icon(Icons.chevron_right, color: AppColors.muted),
|
||||||
if (value != null) setState(() => breed = value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SegmentedButton<PetGender>(
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(
|
|
||||||
value: PetGender.male,
|
|
||||||
icon: Icon(Icons.male),
|
|
||||||
label: Text('男孩'),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: PetGender.female,
|
|
||||||
icon: Icon(Icons.female),
|
|
||||||
label: Text('女孩'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {gender},
|
|
||||||
onSelectionChanged: (value) =>
|
|
||||||
setState(() => gender = value.first),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
ListTile(
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
side: const BorderSide(color: AppColors.border),
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
),
|
|
||||||
leading: const Icon(Icons.cake_outlined),
|
|
||||||
title: const Text('生日'),
|
|
||||||
subtitle: Text(dateText(birthday)),
|
|
||||||
trailing: const Icon(Icons.calendar_month_outlined),
|
|
||||||
onTap: () async {
|
|
||||||
final value = await showDatePicker(
|
|
||||||
context: context,
|
|
||||||
initialDate: birthday,
|
|
||||||
firstDate: DateTime(2000),
|
|
||||||
lastDate: DateTime.now(),
|
|
||||||
);
|
|
||||||
if (value != null) setState(() => birthday = value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: weightController,
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
|
||||||
decimal: true,
|
|
||||||
),
|
|
||||||
decoration: const InputDecoration(labelText: '体重(kg)'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
FilledButton.icon(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(50),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
final name = nameController.text.trim();
|
|
||||||
final weight = double.tryParse(weightController.text);
|
|
||||||
if (name.isEmpty || weight == null || weight <= 0) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('请填写有效的昵称和体重')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Navigator.pop(
|
|
||||||
context,
|
|
||||||
widget.pet.copyWith(
|
|
||||||
name: name,
|
|
||||||
breed: breed,
|
|
||||||
gender: gender,
|
|
||||||
birthday: dateText(birthday),
|
|
||||||
weight: weight,
|
|
||||||
avatarUrl: avatar,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.save_outlined),
|
|
||||||
label: const Text('保存修改'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class VaccineSheet extends StatefulWidget {
|
/// 虚线「添加宠物」卡(05 §4.1:border 色 1.5px dashed,radius 24,高 64)。
|
||||||
const VaccineSheet({required this.record, super.key});
|
class _AddPetCard extends StatelessWidget {
|
||||||
|
const _AddPetCard({required this.onTap});
|
||||||
|
|
||||||
final VaccineRecord record;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
|
||||||
State<VaccineSheet> createState() => _VaccineSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VaccineSheetState extends State<VaccineSheet> {
|
|
||||||
late List<VaccineItem> items;
|
|
||||||
late int totalDoses;
|
|
||||||
late final TextEditingController reminderController;
|
|
||||||
late final TextEditingController dateController;
|
|
||||||
|
|
||||||
int get completed =>
|
|
||||||
items.where((item) => item.status == VaccineStatus.completed).length;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
items = List<VaccineItem>.from(widget.record.items);
|
|
||||||
totalDoses = widget.record.totalDoses;
|
|
||||||
reminderController = TextEditingController(
|
|
||||||
text: widget.record.reminderVaccine,
|
|
||||||
);
|
|
||||||
dateController = TextEditingController(text: widget.record.reminderDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
reminderController.dispose();
|
|
||||||
dateController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void changeTotal(int delta) {
|
|
||||||
final minimum = completed > items.length ? completed : items.length;
|
|
||||||
setState(() => totalDoses = (totalDoses + delta).clamp(minimum, 20));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final progress = totalDoses == 0 ? 0.0 : completed / totalDoses;
|
return CustomPaint(
|
||||||
return Padding(
|
painter: _DashedBorderPainter(
|
||||||
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,
|
color: AppColors.border,
|
||||||
borderRadius: BorderRadius.circular(99),
|
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 Color color;
|
||||||
|
final double strokeWidth;
|
||||||
|
final double radius;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = color
|
||||||
|
..strokeWidth = strokeWidth
|
||||||
|
..style = PaintingStyle.stroke;
|
||||||
|
final path = Path()
|
||||||
|
..addRRect(
|
||||||
|
RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)),
|
||||||
|
);
|
||||||
|
const dashWidth = 6.0;
|
||||||
|
const dashGap = 4.0;
|
||||||
|
for (final metric in path.computeMetrics()) {
|
||||||
|
var distance = 0.0;
|
||||||
|
while (distance < metric.length) {
|
||||||
|
canvas.drawPath(
|
||||||
|
metric.extractPath(distance, distance + dashWidth),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
distance += dashWidth + dashGap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_DashedBorderPainter oldDelegate) =>
|
||||||
|
color != oldDelegate.color ||
|
||||||
|
strokeWidth != oldDelegate.strokeWidth ||
|
||||||
|
radius != oldDelegate.radius;
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,85 +134,6 @@ class PetProfile {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VaccineStatus { completed, pending }
|
|
||||||
|
|
||||||
class VaccineItem {
|
|
||||||
const VaccineItem({
|
|
||||||
required this.id,
|
|
||||||
required this.name,
|
|
||||||
required this.status,
|
|
||||||
required this.date,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String id;
|
|
||||||
final String name;
|
|
||||||
final VaccineStatus status;
|
|
||||||
final String date;
|
|
||||||
|
|
||||||
VaccineItem copyWith({VaccineStatus? status, String? date}) {
|
|
||||||
return VaccineItem(
|
|
||||||
id: id,
|
|
||||||
name: name,
|
|
||||||
status: status ?? this.status,
|
|
||||||
date: date ?? this.date,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
factory VaccineItem.fromJson(Map<String, dynamic> json) {
|
|
||||||
return VaccineItem(
|
|
||||||
id: json['id'] as String,
|
|
||||||
name: json['name'] as String,
|
|
||||||
status: json['status'] == 'completed'
|
|
||||||
? VaccineStatus.completed
|
|
||||||
: VaccineStatus.pending,
|
|
||||||
date: json['date'] as String,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'id': id,
|
|
||||||
'name': name,
|
|
||||||
'status': status.name,
|
|
||||||
'date': date,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class VaccineRecord {
|
|
||||||
const VaccineRecord({
|
|
||||||
required this.completedDoses,
|
|
||||||
required this.totalDoses,
|
|
||||||
required this.items,
|
|
||||||
required this.reminderVaccine,
|
|
||||||
required this.reminderDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int completedDoses;
|
|
||||||
final int totalDoses;
|
|
||||||
final List<VaccineItem> items;
|
|
||||||
final String reminderVaccine;
|
|
||||||
final String reminderDate;
|
|
||||||
|
|
||||||
factory VaccineRecord.fromJson(Map<String, dynamic> json) {
|
|
||||||
return VaccineRecord(
|
|
||||||
completedDoses: json['completedDoses'] as int,
|
|
||||||
totalDoses: json['totalDoses'] as int,
|
|
||||||
items: (json['items'] as List)
|
|
||||||
.map((item) => VaccineItem.fromJson(item as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
reminderVaccine: json['reminderVaccine'] as String,
|
|
||||||
reminderDate: json['reminderDate'] as String,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'completedDoses': completedDoses,
|
|
||||||
'totalDoses': totalDoses,
|
|
||||||
'items': items.map((item) => item.toJson()).toList(),
|
|
||||||
'reminderVaccine': reminderVaccine,
|
|
||||||
'reminderDate': reminderDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class CommentModel {
|
class CommentModel {
|
||||||
const CommentModel({
|
const CommentModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
class AppState extends ChangeNotifier {
|
class AppState extends ChangeNotifier {
|
||||||
static const _petKey = 'patbond_pet';
|
static const _petKey = 'patbond_pet';
|
||||||
static const _vaccinesKey = 'patbond_vaccines';
|
|
||||||
static const _postsKey = 'patbond_posts';
|
static const _postsKey = 'patbond_posts';
|
||||||
static const _locationWeatherKey = 'patbond_location_weather';
|
static const _locationWeatherKey = 'patbond_location_weather';
|
||||||
|
|
||||||
|
/// 首页问候卡 / 创作页 / 主壳头像仍消费的 demo 宠物(T2-12 起档案
|
||||||
|
/// Tab 已切独立 pets feature 真实数据;此 demo 随后续工单收敛)。
|
||||||
PetProfile pet = initialPet;
|
PetProfile pet = initialPet;
|
||||||
VaccineRecord vaccines = initialVaccines;
|
|
||||||
List<PostModel> posts = List<PostModel>.from(initialPosts);
|
List<PostModel> posts = List<PostModel>.from(initialPosts);
|
||||||
LocationWeather locationWeather = initialLocationWeather;
|
LocationWeather locationWeather = initialLocationWeather;
|
||||||
bool isReady = false;
|
bool isReady = false;
|
||||||
@@ -21,18 +21,12 @@ class AppState extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
final savedPet = preferences.getString(_petKey);
|
final savedPet = preferences.getString(_petKey);
|
||||||
final savedVaccines = preferences.getString(_vaccinesKey);
|
|
||||||
final savedPosts = preferences.getString(_postsKey);
|
final savedPosts = preferences.getString(_postsKey);
|
||||||
final savedLocationWeather = preferences.getString(_locationWeatherKey);
|
final savedLocationWeather = preferences.getString(_locationWeatherKey);
|
||||||
|
|
||||||
if (savedPet != null) {
|
if (savedPet != null) {
|
||||||
pet = PetProfile.fromJson(jsonDecode(savedPet) as Map<String, dynamic>);
|
pet = PetProfile.fromJson(jsonDecode(savedPet) as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
if (savedVaccines != null) {
|
|
||||||
vaccines = VaccineRecord.fromJson(
|
|
||||||
jsonDecode(savedVaccines) as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (savedPosts != null) {
|
if (savedPosts != null) {
|
||||||
posts = (jsonDecode(savedPosts) as List)
|
posts = (jsonDecode(savedPosts) as List)
|
||||||
.map((item) => PostModel.fromJson(item as Map<String, dynamic>))
|
.map((item) => PostModel.fromJson(item as Map<String, dynamic>))
|
||||||
@@ -46,7 +40,6 @@ class AppState extends ChangeNotifier {
|
|||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
|
debugPrint('读取本地数据失败,已使用默认数据:$error\n$stackTrace');
|
||||||
pet = initialPet;
|
pet = initialPet;
|
||||||
vaccines = initialVaccines;
|
|
||||||
posts = List<PostModel>.from(initialPosts);
|
posts = List<PostModel>.from(initialPosts);
|
||||||
locationWeather = initialLocationWeather;
|
locationWeather = initialLocationWeather;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,18 +48,6 @@ class AppState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updatePet(PetProfile value) async {
|
|
||||||
pet = value;
|
|
||||||
notifyListeners();
|
|
||||||
await _save(_petKey, value.toJson());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateVaccines(VaccineRecord value) async {
|
|
||||||
vaccines = value;
|
|
||||||
notifyListeners();
|
|
||||||
await _save(_vaccinesKey, value.toJson());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updatePost(PostModel value) async {
|
Future<void> updatePost(PostModel value) async {
|
||||||
final index = posts.indexWhere((post) => post.id == value.id);
|
final index = posts.indexWhere((post) => post.id == value.id);
|
||||||
if (index == -1) return;
|
if (index == -1) return;
|
||||||
@@ -90,14 +71,12 @@ class AppState extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> resetDemoData() async {
|
Future<void> resetDemoData() async {
|
||||||
pet = initialPet;
|
pet = initialPet;
|
||||||
vaccines = initialVaccines;
|
|
||||||
posts = List<PostModel>.from(initialPosts);
|
posts = List<PostModel>.from(initialPosts);
|
||||||
locationWeather = initialLocationWeather;
|
locationWeather = initialLocationWeather;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
preferences.remove(_petKey),
|
preferences.remove(_petKey),
|
||||||
preferences.remove(_vaccinesKey),
|
|
||||||
preferences.remove(_postsKey),
|
preferences.remove(_postsKey),
|
||||||
preferences.remove(_locationWeatherKey),
|
preferences.remove(_locationWeatherKey),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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<void> pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: const Scaffold(body: Text('列表基底')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||||||
|
unawaited(
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
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<Pet>();
|
||||||
|
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<Pet>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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), '走失中');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<String, dynamic>?)> events;
|
||||||
|
late PetAnalytics analytics;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakePetsRepository();
|
||||||
|
controller = PetsController(repository: repository);
|
||||||
|
events = [];
|
||||||
|
analytics = PetAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Map<String, dynamic>?> eventsOf(String name) => [
|
||||||
|
for (final e in events)
|
||||||
|
if (e.$1 == name) e.$2,
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> 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<NavigatorState>(find.byType(Navigator));
|
||||||
|
unawaited(navigator.push(MaterialPageRoute<void>(builder: (_) => form)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpCreate(
|
||||||
|
WidgetTester tester, {
|
||||||
|
PetCreateEntryPoint entryPoint = PetCreateEntryPoint.profileEmptyState,
|
||||||
|
}) {
|
||||||
|
return pumpForm(
|
||||||
|
tester,
|
||||||
|
PetFormPage.create(
|
||||||
|
controller: controller,
|
||||||
|
analytics: analytics,
|
||||||
|
entryPoint: entryPoint,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> selectBreed(WidgetTester tester, String label) async {
|
||||||
|
await tester.tap(find.byType(DropdownButtonFormField<String>));
|
||||||
|
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<int>());
|
||||||
|
});
|
||||||
|
|
||||||
|
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<String>), 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<PetSpecies>), 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 = <int>[];
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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_exceptions.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_models.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_controller.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
|
||||||
|
|
||||||
import '../../helpers/pet_test_helpers.dart';
|
import '../../helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
/// 假仓库:各方法可注入行为,未注入的方法抛 UnimplementedError。
|
|
||||||
class FakePetsRepository implements PetsRepository {
|
|
||||||
Future<List<Pet>> Function()? listPetsHandler;
|
|
||||||
Future<Pet> Function(CreatePetRequest)? createPetHandler;
|
|
||||||
Future<Pet> Function(String)? getPetHandler;
|
|
||||||
Future<Pet> Function(String, UpdatePetRequest)? updatePetHandler;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<Pet>> listPets() =>
|
|
||||||
listPetsHandler?.call() ?? Future.value(const []);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Pet> createPet(CreatePetRequest request) => createPetHandler!(request);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Pet> getPet(String petId) => getPetHandler!(petId);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Pet> 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() {
|
void main() {
|
||||||
late FakePetsRepository repository;
|
late FakePetsRepository repository;
|
||||||
late PetsController controller;
|
late PetsController controller;
|
||||||
@@ -53,7 +22,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('refresh 成功:loading → ready,列表就位', () async {
|
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 = <PetsLoadPhase>[];
|
final phases = <PetsLoadPhase>[];
|
||||||
controller.addListener(() => phases.add(controller.phase));
|
controller.addListener(() => phases.add(controller.phase));
|
||||||
|
|
||||||
@@ -83,7 +52,7 @@ void main() {
|
|||||||
expect(controller.phase, PetsLoadPhase.error);
|
expect(controller.phase, PetsLoadPhase.error);
|
||||||
expect(controller.lastError, isA<ApiNetworkException>());
|
expect(controller.lastError, isA<ApiNetworkException>());
|
||||||
|
|
||||||
repository.listPetsHandler = () async => [pet('p-1')];
|
repository.listPetsHandler = () async => [buildPet('p-1')];
|
||||||
await controller.refresh();
|
await controller.refresh();
|
||||||
|
|
||||||
expect(controller.phase, PetsLoadPhase.ready);
|
expect(controller.phase, PetsLoadPhase.ready);
|
||||||
@@ -92,9 +61,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('createPet:成功后插入列表头', () async {
|
test('createPet:成功后插入列表头', () async {
|
||||||
repository.listPetsHandler = () async => [pet('p-1')];
|
repository.listPetsHandler = () async => [buildPet('p-1')];
|
||||||
await controller.refresh();
|
await controller.refresh();
|
||||||
repository.createPetHandler = (request) async => pet('p-2', name: '咪咪');
|
repository.createPetHandler = (request) async =>
|
||||||
|
buildPet('p-2', name: '咪咪');
|
||||||
|
|
||||||
final created = await controller.createPet(
|
final created = await controller.createPet(
|
||||||
const CreatePetRequest(
|
const CreatePetRequest(
|
||||||
@@ -110,7 +80,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('createPet 失败(40903):类型化异常外抛,列表不变', () async {
|
test('createPet 失败(40903):类型化异常外抛,列表不变', () async {
|
||||||
repository.listPetsHandler = () async => [pet('p-1')];
|
repository.listPetsHandler = () async => [buildPet('p-1')];
|
||||||
await controller.refresh();
|
await controller.refresh();
|
||||||
repository.createPetHandler = (request) async =>
|
repository.createPetHandler = (request) async =>
|
||||||
throw const MicrochipTakenException(message: '芯片号已被登记');
|
throw const MicrochipTakenException(message: '芯片号已被登记');
|
||||||
@@ -130,10 +100,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('updatePet:成功后同步列表内存副本', () async {
|
test('updatePet:成功后同步列表内存副本', () async {
|
||||||
repository.listPetsHandler = () async => [pet('p-1', version: 1)];
|
repository.listPetsHandler = () async => [buildPet('p-1', version: 1)];
|
||||||
await controller.refresh();
|
await controller.refresh();
|
||||||
repository.updatePetHandler = (petId, request) async =>
|
repository.updatePetHandler = (petId, request) async =>
|
||||||
pet('p-1', name: '豆豆二世', version: 2);
|
buildPet('p-1', name: '豆豆二世', version: 2);
|
||||||
|
|
||||||
await controller.updatePet(
|
await controller.updatePet(
|
||||||
'p-1',
|
'p-1',
|
||||||
@@ -155,13 +125,66 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('getPet:详情结果回写列表副本', () async {
|
test('getPet:详情结果回写列表副本', () async {
|
||||||
repository.listPetsHandler = () async => [pet('p-1', version: 1)];
|
repository.listPetsHandler = () async => [buildPet('p-1', version: 1)];
|
||||||
await controller.refresh();
|
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');
|
final detail = await controller.getPet('p-1');
|
||||||
|
|
||||||
expect(detail.version, 5);
|
expect(detail.version, 5);
|
||||||
expect(controller.pets.single.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<ApiNetworkException>()),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = <String?>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPush(Route<dynamic> route, Route<dynamic>? 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<void> pumpPage(WidgetTester tester) {
|
||||||
|
return tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
navigatorObservers: [observer],
|
||||||
|
home: Scaffold(body: PetsPage(controller: controller)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('四态 · loading:拉取期间居中转圈', (tester) async {
|
||||||
|
final completer = Completer<List<Pet>>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
/// pets 域测试样本(契约 openapi.yaml v1.2.0 各 schema 全字段 JSON)。
|
/// pets 域测试样本(契约 openapi.yaml v1.2.0 各 schema 全字段 JSON)
|
||||||
|
/// 与共享假仓库。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
Map<String, Object?> okListEnvelope(List<Object?> data) => {
|
Map<String, Object?> okListEnvelope(List<Object?> data) => {
|
||||||
'code': 0,
|
'code': 0,
|
||||||
'message': 'ok',
|
'message': 'ok',
|
||||||
@@ -105,3 +109,55 @@ Map<String, dynamic> samplePetSummaryJson() => {
|
|||||||
'amountCents': 12850,
|
'amountCents': 12850,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, Object?> overrides = const {},
|
||||||
|
}) => Pet.fromJson(
|
||||||
|
samplePetJson(
|
||||||
|
overrides: {'id': id, 'name': name, 'version': version, ...overrides},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 假仓库:各方法可注入行为;listPets 默认空列表,其余未注入的方法抛
|
||||||
|
/// UnimplementedError(22 号报告 §7:widget 测试注入假仓库的共享实现)。
|
||||||
|
class FakePetsRepository implements PetsRepository {
|
||||||
|
Future<List<Pet>> Function()? listPetsHandler;
|
||||||
|
Future<Pet> Function(CreatePetRequest)? createPetHandler;
|
||||||
|
Future<Pet> Function(String)? getPetHandler;
|
||||||
|
Future<Pet> Function(String, UpdatePetRequest)? updatePetHandler;
|
||||||
|
Future<List<Breed>> Function(PetSpecies?)? listBreedsHandler;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Pet>> listPets() =>
|
||||||
|
listPetsHandler?.call() ?? Future.value(const []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> createPet(CreatePetRequest request) => createPetHandler!(request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> getPet(String petId) => getPetHandler!(petId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> updatePet(String petId, UpdatePetRequest request) =>
|
||||||
|
updatePetHandler!(petId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Breed>> listBreeds({PetSpecies? species}) =>
|
||||||
|
listBreedsHandler?.call(species) ??
|
||||||
|
Future.value([Breed.fromJson(sampleBreedJson())]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) =>
|
||||||
|
throw UnimplementedError('${invocation.memberName}');
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:patbond_flutter/features/auth/session_manager.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'helpers/auth_test_helpers.dart';
|
import 'helpers/auth_test_helpers.dart';
|
||||||
|
import 'helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Patbond renders the main navigation', (tester) async {
|
testWidgets('Patbond renders the main navigation', (tester) async {
|
||||||
@@ -12,7 +13,11 @@ void main() {
|
|||||||
..markAuthenticated();
|
..markAuthenticated();
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
App(sessionManager: session, authRepository: FakeAuthRepository()),
|
App(
|
||||||
|
sessionManager: session,
|
||||||
|
authRepository: FakeAuthRepository(),
|
||||||
|
petsRepository: FakePetsRepository(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user