- 档案 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:
+251
-676
@@ -1,288 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/data/demo_data.dart';
|
||||
import 'package:patbond_flutter/models/models.dart';
|
||||
import 'package:patbond_flutter/state/app_state.dart';
|
||||
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
|
||||
import 'package:patbond_flutter/features/pets/pets_controller.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||
import 'package:patbond_flutter/widgets/common.dart';
|
||||
|
||||
class PetsPage extends StatelessWidget {
|
||||
const PetsPage({required this.appState, super.key});
|
||||
/// 档案 Tab 落地页:宠物列表(T2-12 / 05 号规范 §4.1 P1)。
|
||||
///
|
||||
/// 真实数据经 [PetsController] 四态驱动:loading(居中转圈)、
|
||||
/// empty(空态插画 + 建档 CTA)、error(横幅 + 重试)、ready(列表)。
|
||||
/// 页面不直连 ApiClient,不读写 AppState demo 数据。
|
||||
class PetsPage extends StatefulWidget {
|
||||
const PetsPage({required this.controller, super.key, this.analytics});
|
||||
|
||||
final AppState appState;
|
||||
final PetsController controller;
|
||||
final PetAnalytics? analytics;
|
||||
|
||||
String ageLabel(String value) {
|
||||
final birthday = DateTime.tryParse(value);
|
||||
if (birthday == null) return '年龄未知';
|
||||
final today = DateTime.now();
|
||||
var age = today.year - birthday.year;
|
||||
if (today.month < birthday.month ||
|
||||
(today.month == birthday.month && today.day < birthday.day)) {
|
||||
age--;
|
||||
@override
|
||||
State<PetsPage> createState() => _PetsPageState();
|
||||
}
|
||||
|
||||
class _PetsPageState extends State<PetsPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 主壳挂载即预取(IndexedStack 各 Tab 同时构建);重登后控制器
|
||||
// 已被 reset 回 initial,会重新拉取。
|
||||
if (widget.controller.phase == PetsLoadPhase.initial) {
|
||||
widget.controller.refresh();
|
||||
}
|
||||
return '${age < 0 ? 0 : age} 岁';
|
||||
}
|
||||
|
||||
Future<void> editPet(BuildContext context) async {
|
||||
final value = await showModalBottomSheet<PetProfile>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => EditPetSheet(pet: appState.pet),
|
||||
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 {
|
||||
final value = await showModalBottomSheet<VaccineRecord>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => VaccineSheet(record: appState.vaccines),
|
||||
void _openDetail(Pet pet) {
|
||||
Navigator.of(context).push(
|
||||
fadePageRoute(
|
||||
PetDetailPage(
|
||||
controller: widget.controller,
|
||||
petId: pet.id,
|
||||
analytics: widget.analytics,
|
||||
),
|
||||
settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName),
|
||||
),
|
||||
);
|
||||
if (value != null) await appState.updateVaccines(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pet = appState.pet;
|
||||
final vaccines = appState.vaccines;
|
||||
final progress = vaccines.totalDoses == 0
|
||||
? 0.0
|
||||
: vaccines.completedDoses / vaccines.totalDoses;
|
||||
return ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (context, _) {
|
||||
final controller = widget.controller;
|
||||
switch (controller.phase) {
|
||||
case PetsLoadPhase.initial:
|
||||
case PetsLoadPhase.loading:
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
case PetsLoadPhase.error:
|
||||
return _LoadErrorView(
|
||||
message: petLoadErrorMessage(controller.lastError),
|
||||
onRetry: controller.refresh,
|
||||
);
|
||||
case PetsLoadPhase.ready:
|
||||
if (controller.isEmpty) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: EmptyStateIllustration(
|
||||
icon: Icons.pets,
|
||||
title: '还没有宠物档案',
|
||||
description: '添加毛孩子,开始记录 TA 的健康点滴',
|
||||
ctaLabel: '添加宠物',
|
||||
onCtaPressed: () =>
|
||||
_openCreate(PetCreateEntryPoint.profileEmptyState),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _petList(controller.pets);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => editPet(context),
|
||||
borderRadius: BorderRadius.circular(54),
|
||||
child: RemoteImage(
|
||||
url: pet.avatarUrl,
|
||||
width: 104,
|
||||
height: 104,
|
||||
borderRadius: BorderRadius.circular(52),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: AppColors.primary,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: '编辑资料',
|
||||
onPressed: () => editPet(context),
|
||||
icon: const Icon(
|
||||
Icons.edit,
|
||||
color: Colors.white,
|
||||
size: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${pet.breed} · ${pet.gender == PetGender.male ? '男孩' : '女孩'} · ${ageLabel(pet.birthday)} · 活泼',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Widget _petList(List<Pet> pets) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: widget.controller.refresh,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('我的宠物', style: Theme.of(context).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () => _openCreate(PetCreateEntryPoint.petList),
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('添加'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (final pet in pets) ...[
|
||||
_PetCard(pet: pet, onTap: () => _openDetail(pet)),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => editPet(context),
|
||||
icon: const Icon(Icons.settings_outlined, size: 17),
|
||||
label: const Text('编辑资料'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Text('宠物数据', style: Theme.of(context).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () => editPet(context),
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
label: const Text('编辑'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.monitor_weight_outlined,
|
||||
color: AppColors.primary,
|
||||
value: '${pet.weight.toStringAsFixed(1)}kg',
|
||||
label: '体重',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.vaccines_outlined,
|
||||
color: AppColors.success,
|
||||
value: '${vaccines.completedDoses}/${vaccines.totalDoses}',
|
||||
label: '疫苗进度',
|
||||
onTap: () => editVaccines(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.payments_outlined,
|
||||
color: AppColors.warning,
|
||||
value: '¥328',
|
||||
label: '本月花费',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
SectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('健康概览', style: Theme.of(context).textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
Text('更新于今日', style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 62,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: progress,
|
||||
strokeWidth: 7,
|
||||
backgroundColor: AppColors.border,
|
||||
color: AppColors.success,
|
||||
),
|
||||
Center(
|
||||
child: Text(
|
||||
'${(progress * 100).round()}%',
|
||||
style: const TextStyle(
|
||||
color: AppColors.success,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
progress >= 1 ? '疫苗接种完成' : '疫苗接种进行中',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'下一针:${vaccines.reminderVaccine}\n预计 ${vaccines.reminderDate}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '管理疫苗',
|
||||
onPressed: () => editVaccines(context),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successSurface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColors.success.withAlpha(140)),
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, color: AppColors.success),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'健康提醒:已经半年没有进行体内外驱虫,建议本周安排一次。',
|
||||
style: TextStyle(color: AppColors.successInk, height: 1.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text('成长足迹', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 10),
|
||||
const _TimelineTile(
|
||||
icon: Icons.medical_services_outlined,
|
||||
title: '医疗 · 狂犬疫苗接种',
|
||||
subtitle: '2025-06-12 · 瑞派宠物医院',
|
||||
status: '已完成',
|
||||
),
|
||||
const _TimelineTile(
|
||||
icon: Icons.restaurant_outlined,
|
||||
title: '喂养 · 更换幼犬粮',
|
||||
subtitle: '2025-05-20 · 体重增长稳定',
|
||||
status: '已记录',
|
||||
),
|
||||
],
|
||||
_AddPetCard(onTap: () => _openCreate(PetCreateEntryPoint.petList)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
const _StatCard({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
});
|
||||
/// 加载失败态:InlineErrorBanner + 重试按钮(05 §4.2 错误三层模型)。
|
||||
class _LoadErrorView extends StatelessWidget {
|
||||
const _LoadErrorView({required this.message, required this.onRetry});
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String value;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final String message;
|
||||
final Future<void> Function() onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
InlineErrorBanner(message: message),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: onRetry, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 宠物卡(05 §4.1):头像 lg + 名字 + 元信息 + chevron;非 active
|
||||
/// 状态以 TagPill 标示(图文双通道由状态文案承担)。
|
||||
class _PetCard extends StatelessWidget {
|
||||
const _PetCard({required this.pet, required this.onTap});
|
||||
|
||||
final Pet pet;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
||||
child: Column(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(height: 6),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
const PetAvatar(size: PetAvatarSize.lg),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
pet.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
petMetaLine(pet),
|
||||
style: const TextStyle(
|
||||
color: AppColors.inkSoft,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (pet.status != PetStatus.active)
|
||||
TagPill(
|
||||
petStatusLabel(pet.status),
|
||||
color: pet.status == PetStatus.lost
|
||||
? AppColors.error
|
||||
: AppColors.muted,
|
||||
)
|
||||
else
|
||||
const Icon(Icons.chevron_right, color: AppColors.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -291,433 +217,82 @@ class _StatCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineTile extends StatelessWidget {
|
||||
const _TimelineTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.status,
|
||||
/// 虚线「添加宠物」卡(05 §4.1:border 色 1.5px dashed,radius 24,高 64)。
|
||||
class _AddPetCard extends StatelessWidget {
|
||||
const _AddPetCard({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
painter: _DashedBorderPainter(
|
||||
color: AppColors.border,
|
||||
strokeWidth: 1.5,
|
||||
radius: AppRadius.xl,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'+ 添加宠物',
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryStrong,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashedBorderPainter extends CustomPainter {
|
||||
const _DashedBorderPainter({
|
||||
required this.color,
|
||||
required this.strokeWidth,
|
||||
required this.radius,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String status;
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: SectionCard(
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(child: Icon(icon, color: AppColors.primary, size: 20)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
TagPill(status, color: AppColors.success),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EditPetSheet extends StatefulWidget {
|
||||
const EditPetSheet({required this.pet, super.key});
|
||||
|
||||
final PetProfile pet;
|
||||
|
||||
@override
|
||||
State<EditPetSheet> createState() => _EditPetSheetState();
|
||||
}
|
||||
|
||||
class _EditPetSheetState extends State<EditPetSheet> {
|
||||
late final TextEditingController nameController;
|
||||
late final TextEditingController weightController;
|
||||
late String breed;
|
||||
late PetGender gender;
|
||||
late DateTime birthday;
|
||||
late String avatar;
|
||||
|
||||
static const breeds = ['柴犬', '金毛寻回犬', '柯基', '哈士奇', '英国短毛猫', '其他'];
|
||||
static const avatars = [
|
||||
petAvatar,
|
||||
'https://images.unsplash.com/photo-1517849845537-4d257902454a?auto=format&fit=crop&w=600&q=85',
|
||||
'https://images.unsplash.com/photo-1543466835-00a7907e9de1?auto=format&fit=crop&w=600&q=85',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
nameController = TextEditingController(text: widget.pet.name);
|
||||
weightController = TextEditingController(text: '${widget.pet.weight}');
|
||||
breed = breeds.contains(widget.pet.breed) ? widget.pet.breed : '其他';
|
||||
gender = widget.pet.gender;
|
||||
birthday = DateTime.tryParse(widget.pet.birthday) ?? DateTime(2024, 5, 15);
|
||||
avatar = widget.pet.avatarUrl;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
nameController.dispose();
|
||||
weightController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String dateText(DateTime value) {
|
||||
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
10,
|
||||
20,
|
||||
MediaQuery.viewInsetsOf(context).bottom + 20,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _SheetHandle(),
|
||||
Row(
|
||||
children: [
|
||||
Text('编辑宠物资料', style: Theme.of(context).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Center(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final index = avatars.indexOf(avatar);
|
||||
setState(
|
||||
() => avatar = avatars[(index + 1) % avatars.length],
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(48),
|
||||
child: Stack(
|
||||
children: [
|
||||
RemoteImage(
|
||||
url: avatar,
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: BorderRadius.circular(48),
|
||||
),
|
||||
const Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: CircleAvatar(
|
||||
radius: 15,
|
||||
child: Icon(Icons.edit, size: 15),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: '宠物昵称'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: breed,
|
||||
decoration: const InputDecoration(labelText: '品种'),
|
||||
items: breeds
|
||||
.map(
|
||||
(value) =>
|
||||
DropdownMenuItem(value: value, child: Text(value)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => breed = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<PetGender>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: PetGender.male,
|
||||
icon: Icon(Icons.male),
|
||||
label: Text('男孩'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: PetGender.female,
|
||||
icon: Icon(Icons.female),
|
||||
label: Text('女孩'),
|
||||
),
|
||||
],
|
||||
selected: {gender},
|
||||
onSelectionChanged: (value) =>
|
||||
setState(() => gender = value.first),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
leading: const Icon(Icons.cake_outlined),
|
||||
title: const Text('生日'),
|
||||
subtitle: Text(dateText(birthday)),
|
||||
trailing: const Icon(Icons.calendar_month_outlined),
|
||||
onTap: () async {
|
||||
final value = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: birthday,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (value != null) setState(() => birthday = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: weightController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(labelText: '体重(kg)'),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
onPressed: () {
|
||||
final name = nameController.text.trim();
|
||||
final weight = double.tryParse(weightController.text);
|
||||
if (name.isEmpty || weight == null || weight <= 0) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请填写有效的昵称和体重')));
|
||||
return;
|
||||
}
|
||||
Navigator.pop(
|
||||
context,
|
||||
widget.pet.copyWith(
|
||||
name: name,
|
||||
breed: breed,
|
||||
gender: gender,
|
||||
birthday: dateText(birthday),
|
||||
weight: weight,
|
||||
avatarUrl: avatar,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
label: const Text('保存修改'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VaccineSheet extends StatefulWidget {
|
||||
const VaccineSheet({required this.record, super.key});
|
||||
|
||||
final VaccineRecord record;
|
||||
|
||||
@override
|
||||
State<VaccineSheet> createState() => _VaccineSheetState();
|
||||
}
|
||||
|
||||
class _VaccineSheetState extends State<VaccineSheet> {
|
||||
late List<VaccineItem> items;
|
||||
late int totalDoses;
|
||||
late final TextEditingController reminderController;
|
||||
late final TextEditingController dateController;
|
||||
|
||||
int get completed =>
|
||||
items.where((item) => item.status == VaccineStatus.completed).length;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
items = List<VaccineItem>.from(widget.record.items);
|
||||
totalDoses = widget.record.totalDoses;
|
||||
reminderController = TextEditingController(
|
||||
text: widget.record.reminderVaccine,
|
||||
);
|
||||
dateController = TextEditingController(text: widget.record.reminderDate);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
reminderController.dispose();
|
||||
dateController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void changeTotal(int delta) {
|
||||
final minimum = completed > items.length ? completed : items.length;
|
||||
setState(() => totalDoses = (totalDoses + delta).clamp(minimum, 20));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = totalDoses == 0 ? 0.0 : completed / totalDoses;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
10,
|
||||
20,
|
||||
MediaQuery.viewInsetsOf(context).bottom + 20,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _SheetHandle(),
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(child: Icon(Icons.vaccines_outlined)),
|
||||
const SizedBox(width: 10),
|
||||
Text('疫苗接种管理', style: Theme.of(context).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SectionCard(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('已接种 / 总规划'),
|
||||
const Spacer(),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => changeTotal(-1),
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'$completed / $totalDoses',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
IconButton.filled(
|
||||
onPressed: () => changeTotal(1),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(value: progress, minHeight: 9),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('接种详情', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
...items.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
final done = item.status == VaccineStatus.completed;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: CheckboxListTile(
|
||||
value: done,
|
||||
title: Text(item.name),
|
||||
subtitle: Text(
|
||||
done ? '接种时间:${item.date}' : '计划接种:${item.date}',
|
||||
),
|
||||
secondary: Icon(
|
||||
done ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
color: done ? AppColors.success : AppColors.muted,
|
||||
),
|
||||
onChanged: (_) {
|
||||
final today = DateTime.now();
|
||||
final date =
|
||||
'${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
|
||||
setState(() {
|
||||
items[index] = item.copyWith(
|
||||
status: done
|
||||
? VaccineStatus.pending
|
||||
: VaccineStatus.completed,
|
||||
date: done ? '待定' : date,
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: reminderController,
|
||||
decoration: const InputDecoration(labelText: '下一针提醒'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: dateController,
|
||||
decoration: const InputDecoration(labelText: '预计日期(YYYY-MM-DD)'),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
onPressed: () => Navigator.pop(
|
||||
context,
|
||||
VaccineRecord(
|
||||
completedDoses: completed,
|
||||
totalDoses: totalDoses,
|
||||
items: items,
|
||||
reminderVaccine: reminderController.text.trim(),
|
||||
reminderDate: dateController.text.trim(),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
label: const Text('保存疫苗记录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetHandle extends StatelessWidget {
|
||||
const _SheetHandle();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 5,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
),
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user