- 档案 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:
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user