import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart'; import 'package:patbond_flutter/features/pets/pet_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_display.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_models.dart'; import 'package:patbond_flutter/features/pets/pets_controller.dart'; /// 品种下拉里「自定义品种」哨兵值(与目录 breedId 互斥)。 const _customBreedSentinel = '__custom__'; /// 建宠 / 编辑宠物资料表单页(T2-12)。 /// /// - 字段对齐冻结契约:昵称*、物种*(创建后不可改)、性别*(契约必填)、 /// 品种(目录选择与自定义互斥、整体替换)、生日(+是否估算)、 /// 芯片号、性格;头像按 ADR-010 本地占位、不做上传。 /// - 错误分层沿用登录纵切:失焦+提交双校验走 errorText(onChanged 即清)、 /// 不可归属错误走 InlineErrorBanner、网络瞬态走 SnackBar+重试。 /// - 409/40902 版本冲突:提示「已被修改」并自动拉取最新版本(保留用户 /// 输入、更新乐观锁 version),用户核对后重新保存;40903 芯片号冲突 /// 为字段级报错。 /// - 埋点(仅创建模式):首次输入触发 pet_create_started;成功/失败 /// 经 [PetAnalytics] 强类型上报。表单页路由名 pet_form 由调用方在 /// push 时给定(创建态),page_viewed 走既有 RouteObserver。 class PetFormPage extends StatefulWidget { const PetFormPage.create({ required this.controller, super.key, this.analytics, this.entryPoint = PetCreateEntryPoint.petList, }) : pet = null; const PetFormPage.edit({ required this.controller, required Pet this.pet, super.key, this.analytics, }) : entryPoint = null; final PetsController controller; /// null 为创建模式。 final Pet? pet; final PetAnalytics? analytics; /// 创建模式的入口(pet_create_started.entryPoint)。 final PetCreateEntryPoint? entryPoint; bool get isCreate => pet == null; @override State createState() => _PetFormPageState(); } class _PetFormPageState extends State { final _nameCtrl = TextEditingController(); final _customBreedCtrl = TextEditingController(); final _microchipCtrl = TextEditingController(); final _personalityCtrl = TextEditingController(); PetSpecies _species = PetSpecies.dog; PetSex? _sex; DateTime? _birthDate; bool _birthDateEstimated = false; /// 目录 breedId 或 [_customBreedSentinel];null 未选择。 String? _breedChoice; List? _breeds; bool _breedsLoading = false; bool _breedsFailed = false; String? _nameError; String? _sexError; String? _breedError; String? _microchipError; String? _formError; bool _submitting = false; /// 编辑基线:40902 冲突刷新后更新(version 与差量计算的比较基准)。 Pet? _basePet; bool _startedFired = false; int _attemptSeq = 0; late final DateTime _openedAt; @override void initState() { super.initState(); _openedAt = DateTime.now(); final pet = widget.pet; _basePet = pet; if (pet != null) { _nameCtrl.text = pet.name; _species = pet.species; _sex = pet.sex; _birthDate = pet.birthDate; _birthDateEstimated = pet.birthDateEstimated; _microchipCtrl.text = pet.microchipNo ?? ''; _personalityCtrl.text = pet.personality ?? ''; if (pet.customBreedName != null) { _breedChoice = _customBreedSentinel; _customBreedCtrl.text = pet.customBreedName!; } else { _breedChoice = pet.breedId; } } _loadBreeds(); } @override void dispose() { _nameCtrl.dispose(); _customBreedCtrl.dispose(); _microchipCtrl.dispose(); _personalityCtrl.dispose(); super.dispose(); } // ---- 品种目录(网络字典:加载 / 失败重试 / 空目录自定义兜底)---- Future _loadBreeds() async { setState(() { _breedsLoading = true; _breedsFailed = false; }); try { final breeds = await widget.controller.loadBreeds(_species); if (!mounted) return; setState(() { _breeds = breeds; _breedsLoading = false; }); } on ApiException { if (!mounted) return; setState(() { _breedsLoading = false; _breedsFailed = true; // 目录不可用不阻塞建档:回落到自定义品种输入。 _breedChoice ??= _customBreedSentinel; }); } } // ---- 埋点(仅创建模式)---- void _markStarted() { if (!widget.isCreate || _startedFired) return; _startedFired = true; widget.analytics?.createStarted(entryPoint: widget.entryPoint!); } void _trackCreateFailed(PetCreateFailureReason reason, [int? errorCode]) { if (!widget.isCreate) return; widget.analytics?.createFailed( reason: reason, attemptSeq: _attemptSeq, errorCode: errorCode, ); } // ---- 校验(失焦 + 提交双校验,onChanged 即清)---- String? _validateName() => _nameCtrl.text.trim().isEmpty ? '请输入宠物昵称' : null; String? _validateSex() => _sex == null ? '请选择性别' : null; String? _validateBreed() { if (_breedChoice == null) return '请选择品种'; if (_breedChoice == _customBreedSentinel && _customBreedCtrl.text.trim().isEmpty) { return '请输入品种名称'; } return null; } void _clearError(void Function() clear) { setState(() { clear(); _formError = null; }); } void _showFormError(String message) { setState(() => _formError = message); SemanticsService.sendAnnouncement( View.of(context), message, TextDirection.ltr, ); } // ---- 提交 ---- Future _submit() async { if (_submitting) return; _attemptSeq++; final nameError = _validateName(); final sexError = _validateSex(); final breedError = _validateBreed(); if (nameError != null || sexError != null || breedError != null) { setState(() { _nameError = nameError; _sexError = sexError; _breedError = breedError; }); _trackCreateFailed(PetCreateFailureReason.validationError); return; } setState(() { _submitting = true; _formError = null; }); try { if (widget.isCreate) { await _create(); } else { await _update(); } } on MicrochipTakenException { if (!mounted) return; setState(() => _microchipError = '该芯片号已被登记,请核对后重试'); _trackCreateFailed(PetCreateFailureReason.validationError, 40903); } on PetVersionConflictException { await _handleVersionConflict(); } on PetNotFoundException { if (!mounted) return; // 编辑目标已不存在(防枚举三态同响应):返回列表并刷新。 final navigator = Navigator.of(context); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除'))); widget.controller.refresh(); navigator.pop(); } on PetAccessDeniedException { if (!mounted) return; _showFormError('你没有权限修改该宠物的资料'); } on ApiRateLimitException { if (!mounted) return; _showFormError('操作过于频繁,请稍后再试'); _trackCreateFailed(PetCreateFailureReason.rateLimited); } on ApiBusinessException catch (error) { if (!mounted) return; _showFormError( error.code == ApiCodes.paramError ? '请检查填写内容后重试' : '保存失败,请稍后重试', ); _trackCreateFailed( error.code == ApiCodes.paramError ? PetCreateFailureReason.validationError : PetCreateFailureReason.serverError, error.code, ); } on ApiNetworkException { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('网络异常,请检查网络后重试'), action: SnackBarAction(label: '重试', onPressed: _submit), ), ); _trackCreateFailed(PetCreateFailureReason.networkError); } on SessionExpiredException { // 会话失效:认证状态机自动回登录页,表单不再提示。 } finally { if (mounted) setState(() => _submitting = false); } } bool get _isCustomBreed => _breedChoice == _customBreedSentinel; Future _create() async { final request = CreatePetRequest( name: _nameCtrl.text.trim(), species: _species, sex: _sex!, breedId: _isCustomBreed ? null : _breedChoice, customBreedName: _isCustomBreed ? _customBreedCtrl.text.trim() : null, birthDate: _birthDate, birthDateEstimated: _birthDate != null ? _birthDateEstimated : null, microchipNo: _textOrNull(_microchipCtrl), personality: _textOrNull(_personalityCtrl), ); // petIndex:该用户第几只宠物(H2 假设数据源)。 final petIndex = widget.controller.pets.length + 1; final pet = await widget.controller.createPet(request); widget.analytics?.createSucceeded( durationMs: DateTime.now().difference(_openedAt).inMilliseconds, species: _species, petIndex: petIndex, ); if (mounted) Navigator.of(context).pop(pet); } Future _update() async { final request = _buildUpdateRequest(); if (request == null) { // 无变更:直接返回,不发空 PATCH。 Navigator.of(context).pop(); return; } final pet = await widget.controller.updatePet(_basePet!.id, request); if (mounted) Navigator.of(context).pop(pet); } /// 差量构造部分更新请求(缺席字段不发;契约不支持清空回 null, /// 清空的输入视为未变更)。全部未变更返回 null。 UpdatePetRequest? _buildUpdateRequest() { final base = _basePet!; final name = _nameCtrl.text.trim(); final String? breedId = _isCustomBreed ? null : _breedChoice; final String? customName = _isCustomBreed ? _customBreedCtrl.text.trim() : null; final breedChanged = breedId != base.breedId || customName != base.customBreedName; final microchip = _textOrNull(_microchipCtrl); final personality = _textOrNull(_personalityCtrl); final birthChanged = _birthDate != null && !_sameDate(_birthDate, base.birthDate); final estimatedChanged = _birthDate != null && _birthDateEstimated != base.birthDateEstimated; final request = UpdatePetRequest( version: base.version, name: name != base.name ? name : null, sex: _sex != base.sex ? _sex : null, // 品种对整体替换:任一半变化则整对发送。 breedId: breedChanged ? breedId : null, customBreedName: breedChanged ? customName : null, birthDate: birthChanged ? _birthDate : null, birthDateEstimated: estimatedChanged ? _birthDateEstimated : null, microchipNo: microchip != null && microchip != base.microchipNo ? microchip : null, personality: personality != null && personality != base.personality ? personality : null, ); // 只剩 version 一个键 → 无实际变更。 return request.toJson().length == 1 ? null : request; } /// 40902:提示 + 刷新路径——拉取最新版本更新乐观锁基线, /// 保留用户输入,由用户核对后重新保存(T2-11 §7 定型路径)。 Future _handleVersionConflict() async { try { final fresh = await widget.controller.getPet(_basePet!.id); if (!mounted) return; setState(() => _basePet = fresh); _showFormError('资料已在其他设备被修改,已获取最新版本,请核对后重新保存'); } on ApiException { if (!mounted) return; _showFormError('资料已在其他设备被修改,请返回后刷新重试'); } } static String? _textOrNull(TextEditingController controller) { final text = controller.text.trim(); return text.isEmpty ? null : text; } static bool _sameDate(DateTime? a, DateTime? b) { if (a == null || b == null) return a == b; return a.year == b.year && a.month == b.month && a.day == b.day; } // ---- UI ---- @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, foregroundColor: AppColors.ink, title: Text(widget.isCreate ? '添加宠物' : '编辑宠物资料'), centerTitle: true, titleTextStyle: const TextStyle( color: AppColors.ink, fontSize: 16, fontWeight: FontWeight.w800, ), ), body: SafeArea( child: ListView( padding: const EdgeInsets.fromLTRB(20, 4, 20, 30), children: [ // 头像本地占位:M2 不做上传(ADR-010 / D2-1)。 const Center(child: PetAvatar(size: PetAvatarSize.xl)), const SizedBox(height: 18), Focus( onFocusChange: (hasFocus) { if (!hasFocus && mounted) { setState(() => _nameError = _validateName()); } }, child: AppTextField( label: '宠物昵称', controller: _nameCtrl, prefixIcon: Icons.pets_outlined, errorText: _nameError, enabled: !_submitting, textInputAction: TextInputAction.next, onChanged: (_) { _markStarted(); if (_nameError != null || _formError != null) { _clearError(() => _nameError = null); } }, ), ), const SizedBox(height: 18), _FieldLabel(widget.isCreate ? '物种' : '物种(创建后不可修改)'), const SizedBox(height: 8), if (widget.isCreate) SegmentedButton( segments: [ for (final species in PetSpecies.values) ButtonSegment( value: species, label: Text(petSpeciesLabel(species)), ), ], selected: {_species}, onSelectionChanged: _submitting ? null : (value) { _markStarted(); setState(() { _species = value.first; // 物种切换:品种目录随物种重载,已选品种作废。 _breedChoice = null; _breedError = null; _breeds = null; }); _loadBreeds(); }, ) else Text( petSpeciesLabel(_species), style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 18), const _FieldLabel('性别'), const SizedBox(height: 8), SegmentedButton( emptySelectionAllowed: true, segments: const [ ButtonSegment( value: PetSex.male, icon: Icon(Icons.male), label: Text('男孩'), ), ButtonSegment( value: PetSex.female, icon: Icon(Icons.female), label: Text('女孩'), ), ButtonSegment(value: PetSex.unknown, label: Text('未知')), ], selected: {?_sex}, onSelectionChanged: _submitting ? null : (value) { _markStarted(); setState(() { _sex = value.isEmpty ? null : value.first; _sexError = null; _formError = null; }); }, ), if (_sexError != null) ...[ const SizedBox(height: 6), _FieldError(_sexError!), ], const SizedBox(height: 18), const _FieldLabel('品种'), const SizedBox(height: 8), ..._breedSection(), const SizedBox(height: 18), _dateTile(), if (_birthDate != null) CheckboxListTile( dense: true, contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, title: const Text('生日为估算日期', style: TextStyle(fontSize: 13)), value: _birthDateEstimated, onChanged: _submitting ? null : (value) { _markStarted(); setState(() => _birthDateEstimated = value ?? false); }, ), const SizedBox(height: 12), AppTextField( label: '芯片号(可选)', controller: _microchipCtrl, prefixIcon: Icons.qr_code_2_outlined, errorText: _microchipError, enabled: !_submitting, textInputAction: TextInputAction.next, onChanged: (_) { _markStarted(); if (_microchipError != null || _formError != null) { _clearError(() => _microchipError = null); } }, ), const SizedBox(height: 12), AppTextField( label: '性格(可选,如:活泼)', controller: _personalityCtrl, prefixIcon: Icons.emoji_emotions_outlined, enabled: !_submitting, textInputAction: TextInputAction.done, onChanged: (_) => _markStarted(), onSubmitted: (_) => _submit(), ), if (_formError != null) ...[ const SizedBox(height: 16), InlineErrorBanner(message: _formError!), ], const SizedBox(height: 24), PrimaryButton( label: widget.isCreate ? '保存档案' : '保存修改', isLoading: _submitting, onPressed: _submit, ), ], ), ), ); } List _breedSection() { if (_breedsLoading) { return const [ SizedBox( height: 52, child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ), ]; } final widgets = []; if (_breedsFailed) { widgets ..add( Row( children: [ const Expanded( child: Text( '品种目录加载失败,可先填写自定义品种', style: TextStyle(color: AppColors.error, fontSize: 12), ), ), TextButton(onPressed: _loadBreeds, child: const Text('重试')), ], ), ) ..add(const SizedBox(height: 8)); } else { final breeds = _breeds ?? const []; final knownIds = breeds.map((breed) => breed.id).toSet(); final base = _basePet; widgets ..add( DropdownButtonFormField( initialValue: _breedChoice, decoration: InputDecoration( labelText: '品种', errorText: _isCustomBreed ? null : _breedError, ), items: [ // 编辑时目录中缺席的既有品种保底成项,避免下拉值失配。 if (base?.breedId != null && !knownIds.contains(base!.breedId)) DropdownMenuItem( value: base.breedId, child: Text(base.breedDisplayName ?? '当前品种'), ), for (final breed in breeds) DropdownMenuItem( value: breed.id, child: Text(breed.displayName), ), const DropdownMenuItem( value: _customBreedSentinel, child: Text('自定义品种…'), ), ], onChanged: _submitting ? null : (value) { _markStarted(); setState(() { _breedChoice = value; _breedError = null; _formError = null; }); }, ), ) ..add(const SizedBox(height: 12)); } if (_isCustomBreed) { widgets.add( Focus( onFocusChange: (hasFocus) { if (!hasFocus && mounted) { setState(() => _breedError = _validateBreed()); } }, child: AppTextField( label: '品种名称', controller: _customBreedCtrl, prefixIcon: Icons.edit_note_outlined, errorText: _breedError, enabled: !_submitting, textInputAction: TextInputAction.next, onChanged: (_) { _markStarted(); if (_breedError != null || _formError != null) { _clearError(() => _breedError = null); } }, ), ), ); } else if (_breedError != null && _breedsFailed) { widgets.add(_FieldError(_breedError!)); } return widgets; } Widget _dateTile() { return ListTile( shape: RoundedRectangleBorder( side: const BorderSide(color: AppColors.border), borderRadius: BorderRadius.circular(AppRadius.lg), ), tileColor: AppColors.surface, leading: const Icon(Icons.cake_outlined, color: AppColors.muted), title: const Text('生日(可选)', style: TextStyle(fontSize: 14)), subtitle: Text( _birthDate == null ? '未填写' : dateToJson(_birthDate!), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), trailing: const Icon( Icons.calendar_month_outlined, color: AppColors.muted, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); final value = await showDatePicker( context: context, initialDate: _birthDate ?? DateTime(now.year - 1, now.month), firstDate: DateTime(1990), lastDate: now, ); if (value != null && mounted) { _markStarted(); setState(() => _birthDate = value); } }, ); } } class _FieldLabel extends StatelessWidget { const _FieldLabel(this.text); final String text; @override Widget build(BuildContext context) { return Text( text, style: const TextStyle( color: AppColors.inkSoft, fontSize: 13, fontWeight: FontWeight.w600, ), ); } } class _FieldError extends StatelessWidget { const _FieldError(this.text); final String text; @override Widget build(BuildContext context) { return Text( text, style: const TextStyle(color: AppColors.error, fontSize: 12), ); } }