294fc4a781
用户实测已因日期选择器误录:Flutter 原生日历只给年份网格、月份必须靠 < > 逐月点,从 9 月回 4 月要点 5 次,他把当月(2026-09)的就医记录记成了 2026-04-09,进而误判「本月花费 ¥0」是聚合坏了。 新增 lib/core/widgets/app_date_picker.dart,全仓 7 处裸 showDatePicker 收口 (改造后 lib/ 下 showDatePicker 只出现在该文件内部一次): - pickAppDate(...):calendar 首屏 + 保留头部铅笔切手输;initialDate 自动夹进 [firstDate, lastDate] 防原生越界断言(调用方常传「当前值 ?? 今天」,而 「到期日期」的 firstDate 就是今天,历史值可能已越界);返回值统一抹时分秒; 中文文案一律交给 zh-CN 本地化,不硬编码,避免两处文案漂移。 - AppDateFieldTrailing(...):7 处日期行统一「今天 + 日历图标」。今天越界自动 隐藏按钮、提交中禁用、触控 44×44、primaryStrong 白底 4.49:1。 入口模式取舍:不用 calendarOnly——它恰好会砍掉手输按钮,把「录一个已知日期」 这条唯一快路堵死;也不用 input 首屏——「记今天」这类高频场景敲 8 个数字更慢。 两条路都留着最省事。 「今天」为何放表单行而非弹窗内:原生 showDatePicker 无法注入自定义动作 (builder 只能包裹整个 Dialog,拿不到内部选中态;塞进 Column 还会因 Dialog 在无界高度下贪心布局而溢出)。放表单行反而更快——一键落值连弹窗都不用开, 把容易走错的月份导航整段绕开,且一次实现 7 处形态完全一致。 各调用点原有的 firstDate/lastDate 业务约束原样传入、一字未改(健康事件 不许未来、到期日不许补记过去、疫苗 allowFuture 双态、生日不许未来), 并由 widget 测试直接断言 DatePickerDialog.firstDate/lastDate 防后续悄悄放宽。 顺带修配色:此前从未定制 datePickerTheme,选中日直接吃 ColorScheme.fromSeed 由珊瑚橙派生的暗红棕,与品牌脱节。新增 _datePickerTheme 只复用 05 号规范 (iteration-2/05、iteration-3/05)已审计的色对,不新造色值:头部 surfaceTint + primaryDark 7.98:1(选中 chip 同款)、选中日/年 primaryStrong 实底白字 4.49:1、今日 primaryStrong 1.5px 描边、星期表头 inkSoft 6.59:1、 越界日 muted(DEBT-2 允许的禁用态用途)。headerHeadlineStyle 取 22px (默认 32):中文「9月10日周四」在横屏侧栏头部 26px 起就折行。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
721 lines
24 KiB
Dart
721 lines
24 KiB
Dart
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_date_picker.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: AppDateFieldTrailing(
|
||
firstDate: DateTime(1990),
|
||
lastDate: DateTime.now(),
|
||
enabled: !_submitting,
|
||
onToday: (value) {
|
||
_markStarted();
|
||
setState(() => _birthDate = value);
|
||
},
|
||
),
|
||
enabled: !_submitting,
|
||
onTap: () async {
|
||
final now = DateTime.now();
|
||
final value = await pickAppDate(
|
||
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),
|
||
);
|
||
}
|
||
}
|