- pet_models.dart:pets 域 DTO 逐字段对齐冻结契约(宠物/品种/体重/ 疫苗目录/疫苗/健康事件/照护提醒/聚合摘要 + cursor 分页信封), 枚举严格解析,请求体部分更新语义(缺席字段不出现) - pets_repository.dart:PetsRepository 抽象 + ApiPetsRepository, 12 路径 18 操作全覆盖;四个 POST 携带 Idempotency-Key; 复用既有 ApiClient token 拦截与单飞刷新重放 - pet_exceptions.dart:新 8 错误码映射为类型化异常 (40300/40401/40402/40902/40903/40904/42201/42202), 仍可按 ApiBusinessException 基类捕获 - pets_controller.dart:宠物档案状态自 AppState 拆出 (Controller → Repository → API Client 分层,四态就绪供 T2-12) - money.dart:元/分换算工具(DTO 层保持整数分,UI 层 T2-14 使用) - api_client.dart:新增 patbondPetApiBaseUrl(:8083,--dart-define 可覆盖)与 query 参数支持;api_exception.dart 补 pets 域错误码 - 测试 64 → 126:DTO 映射、错误类型化映射、请求线路 (路径/方法/鉴权/分页/幂等键/tz)、控制器状态机、金额换算 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
/// 金额换算工具:契约传输整数分(amountCents),UI 展示以元。
|
||||
/// DTO 层保持整数分不换算(开发计划 §4.3);本工具供 UI 层(T2-14)使用。
|
||||
library;
|
||||
|
||||
/// 整数分 → 元展示字符串。整元不带小数("128"),
|
||||
/// 非整元固定两位("128.50")。负数入参非法(契约金额非负)。
|
||||
String formatCentsAsYuan(int cents) {
|
||||
if (cents < 0) {
|
||||
throw ArgumentError.value(cents, 'cents', '金额不得为负');
|
||||
}
|
||||
final yuan = cents ~/ 100;
|
||||
final remainder = cents % 100;
|
||||
if (remainder == 0) return '$yuan';
|
||||
return '$yuan.${remainder.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 元输入串 → 整数分。接受整数或最多两位小数("12"、"12.5"、"12.50");
|
||||
/// 空白、非法字符、超两位小数、负数返回 null(表单层据此报「金额格式不正确」)。
|
||||
int? parseYuanToCents(String input) {
|
||||
final trimmed = input.trim();
|
||||
final match = RegExp(r'^(\d+)(?:\.(\d{1,2}))?$').firstMatch(trimmed);
|
||||
if (match == null) return null;
|
||||
final yuan = int.parse(match.group(1)!);
|
||||
final fractional = match.group(2);
|
||||
final cents = switch (fractional) {
|
||||
null => 0,
|
||||
_ => int.parse(fractional.padRight(2, '0')),
|
||||
};
|
||||
return yuan * 100 + cents;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||
|
||||
/// pets 域类型化业务异常(契约 v1.2.0 定型的 8 个新错误码,
|
||||
/// 21 号收口报告 §1)。全部继承 [ApiBusinessException],
|
||||
/// 既有按基类捕获的通用错误处理不受影响。
|
||||
|
||||
/// 40300:对可见宠物无相应操作权限(viewer 写记录、caregiver/viewer 改档案)。
|
||||
final class PetAccessDeniedException extends ApiBusinessException {
|
||||
const PetAccessDeniedException({required super.message})
|
||||
: super(code: ApiCodes.petAccessDenied);
|
||||
}
|
||||
|
||||
/// 40401:宠物不存在 / 已软删 / 与调用者无关系(防枚举,三种情况响应一致)。
|
||||
final class PetNotFoundException extends ApiBusinessException {
|
||||
const PetNotFoundException({required super.message})
|
||||
: super(code: ApiCodes.petNotFound);
|
||||
}
|
||||
|
||||
/// 40402:记录不存在或记录所属宠物对调用者不可见(记录级防枚举)。
|
||||
final class PetRecordNotFoundException extends ApiBusinessException {
|
||||
const PetRecordNotFoundException({required super.message})
|
||||
: super(code: ApiCodes.recordNotFound);
|
||||
}
|
||||
|
||||
/// 40902:乐观锁版本冲突(version 过期;提醒的条件更新守卫落空同码)。
|
||||
/// 客户端处理:刷新取新数据后重提。
|
||||
final class PetVersionConflictException extends ApiBusinessException {
|
||||
const PetVersionConflictException({required super.message})
|
||||
: super(code: ApiCodes.versionConflict);
|
||||
}
|
||||
|
||||
/// 40903:芯片号已被登记(跨用户唯一)。
|
||||
final class MicrochipTakenException extends ApiBusinessException {
|
||||
const MicrochipTakenException({required super.message})
|
||||
: super(code: ApiCodes.microchipTaken);
|
||||
}
|
||||
|
||||
/// 40904:同宠物同疫苗同系列同剂次的非 cancelled 疫苗记录已存在
|
||||
/// (cancel 后同剂次可重新登记)。
|
||||
final class VaccinationDoseExistsException extends ApiBusinessException {
|
||||
const VaccinationDoseExistsException({required super.message})
|
||||
: super(code: ApiCodes.vaccinationDoseExists);
|
||||
}
|
||||
|
||||
/// 42201:疫苗状态机非法迁移或状态-日期规则违反(HTTP 422)。
|
||||
final class VaccinationRuleException extends ApiBusinessException {
|
||||
const VaccinationRuleException({required super.message})
|
||||
: super(code: ApiCodes.vaccinationRuleViolation);
|
||||
}
|
||||
|
||||
/// 42202:提醒状态机非法迁移或 completed-completedAt 一致性违反(HTTP 422)。
|
||||
final class CareReminderRuleException extends ApiBusinessException {
|
||||
const CareReminderRuleException({required super.message})
|
||||
: super(code: ApiCodes.careReminderRuleViolation);
|
||||
}
|
||||
|
||||
/// 把通用业务异常按 pets 域错误码升格为类型化异常;
|
||||
/// 未覆盖的码(40000 参数错误等)原样返回,沿用通用处理。
|
||||
ApiBusinessException mapPetBusinessException(ApiBusinessException error) {
|
||||
return switch (error.code) {
|
||||
ApiCodes.petAccessDenied => PetAccessDeniedException(
|
||||
message: error.message,
|
||||
),
|
||||
ApiCodes.petNotFound => PetNotFoundException(message: error.message),
|
||||
ApiCodes.recordNotFound => PetRecordNotFoundException(
|
||||
message: error.message,
|
||||
),
|
||||
ApiCodes.versionConflict => PetVersionConflictException(
|
||||
message: error.message,
|
||||
),
|
||||
ApiCodes.microchipTaken => MicrochipTakenException(message: error.message),
|
||||
ApiCodes.vaccinationDoseExists => VaccinationDoseExistsException(
|
||||
message: error.message,
|
||||
),
|
||||
ApiCodes.vaccinationRuleViolation => VaccinationRuleException(
|
||||
message: error.message,
|
||||
),
|
||||
ApiCodes.careReminderRuleViolation => CareReminderRuleException(
|
||||
message: error.message,
|
||||
),
|
||||
_ => error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
/// pets 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.2.0,
|
||||
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
|
||||
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
||||
library;
|
||||
|
||||
/// 物种(创建即定,不可修改)。
|
||||
enum PetSpecies {
|
||||
dog,
|
||||
cat,
|
||||
other;
|
||||
|
||||
static PetSpecies fromJson(String value) =>
|
||||
_enumFromJson(values, value, 'species');
|
||||
}
|
||||
|
||||
/// 性别。
|
||||
enum PetSex {
|
||||
male,
|
||||
female,
|
||||
unknown;
|
||||
|
||||
static PetSex fromJson(String value) => _enumFromJson(values, value, 'sex');
|
||||
}
|
||||
|
||||
/// 宠物状态(deleted 为内部软删态,接口永不返回)。
|
||||
enum PetStatus {
|
||||
active,
|
||||
lost,
|
||||
deceased,
|
||||
archived;
|
||||
|
||||
static PetStatus fromJson(String value) =>
|
||||
_enumFromJson(values, value, 'status');
|
||||
}
|
||||
|
||||
/// 调用者对宠物的权限角色(客户端据此显隐写入口)。
|
||||
enum PetRole {
|
||||
owner,
|
||||
caregiver,
|
||||
viewer;
|
||||
|
||||
static PetRole fromJson(String value) =>
|
||||
_enumFromJson(values, value, 'myRole');
|
||||
}
|
||||
|
||||
/// 体重记录来源。
|
||||
enum WeightSource {
|
||||
manual,
|
||||
clinic,
|
||||
device;
|
||||
|
||||
static WeightSource fromJson(String value) =>
|
||||
_enumFromJson(values, value, 'source');
|
||||
}
|
||||
|
||||
/// 疫苗记录状态。
|
||||
enum VaccinationStatus {
|
||||
scheduled,
|
||||
completed,
|
||||
cancelled;
|
||||
|
||||
static VaccinationStatus fromJson(String value) =>
|
||||
_enumFromJson(values, value, 'status');
|
||||
}
|
||||
|
||||
/// 健康事件六类类型。
|
||||
enum HealthEventType {
|
||||
medical,
|
||||
feeding,
|
||||
deworming,
|
||||
grooming,
|
||||
measurement,
|
||||
note;
|
||||
|
||||
static HealthEventType fromJson(String value) =>
|
||||
_enumFromJson(values, value, 'eventType');
|
||||
}
|
||||
|
||||
/// 照护提醒四类类型。
|
||||
enum CareReminderType {
|
||||
deworming,
|
||||
checkup,
|
||||
medication,
|
||||
other;
|
||||
|
||||
static CareReminderType fromJson(String value) =>
|
||||
_enumFromJson(values, value, 'reminderType');
|
||||
}
|
||||
|
||||
/// 照护提醒状态。
|
||||
enum CareReminderStatus {
|
||||
pending,
|
||||
completed,
|
||||
dismissed;
|
||||
|
||||
static CareReminderStatus fromJson(String value) =>
|
||||
_enumFromJson(values, value, 'status');
|
||||
}
|
||||
|
||||
/// 摘要「下次接种」的取值来源。
|
||||
enum NextVaccinationSource {
|
||||
planned,
|
||||
nextDue;
|
||||
|
||||
static NextVaccinationSource fromJson(String value) =>
|
||||
_enumFromJson(values, value, 'source');
|
||||
}
|
||||
|
||||
T _enumFromJson<T extends Enum>(List<T> values, String raw, String field) {
|
||||
for (final value in values) {
|
||||
if (value.name == raw) return value;
|
||||
}
|
||||
throw FormatException('未知的 $field 取值:$raw');
|
||||
}
|
||||
|
||||
/// 日期字段(契约 format: date)序列化为 YYYY-MM-DD。
|
||||
String dateToJson(DateTime date) {
|
||||
final y = date.year.toString().padLeft(4, '0');
|
||||
final m = date.month.toString().padLeft(2, '0');
|
||||
final d = date.day.toString().padLeft(2, '0');
|
||||
return '$y-$m-$d';
|
||||
}
|
||||
|
||||
DateTime? _dateOrNull(Object? value) =>
|
||||
value == null ? null : DateTime.parse(value as String);
|
||||
|
||||
/// cursor 分页正典信封 `{items, nextCursor, hasMore}`(体重、健康事件)。
|
||||
class CursorPage<T> {
|
||||
const CursorPage({
|
||||
required this.items,
|
||||
required this.nextCursor,
|
||||
required this.hasMore,
|
||||
});
|
||||
|
||||
factory CursorPage.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Map<String, dynamic>) itemFromJson,
|
||||
) {
|
||||
return CursorPage(
|
||||
items: (json['items'] as List)
|
||||
.map((item) => itemFromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
// 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。
|
||||
nextCursor: json['nextCursor'] as String?,
|
||||
hasMore: json['hasMore'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
final List<T> items;
|
||||
final String? nextCursor;
|
||||
final bool hasMore;
|
||||
}
|
||||
|
||||
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
|
||||
/// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。
|
||||
class Pet {
|
||||
const Pet({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.species,
|
||||
required this.breedId,
|
||||
required this.breedDisplayName,
|
||||
required this.customBreedName,
|
||||
required this.sex,
|
||||
required this.birthDate,
|
||||
required this.birthDateEstimated,
|
||||
required this.personality,
|
||||
required this.microchipNo,
|
||||
required this.sterilizedOn,
|
||||
required this.status,
|
||||
required this.myRole,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.version,
|
||||
});
|
||||
|
||||
factory Pet.fromJson(Map<String, dynamic> json) {
|
||||
return Pet(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
species: PetSpecies.fromJson(json['species'] as String),
|
||||
breedId: json['breedId'] as String?,
|
||||
breedDisplayName: json['breedDisplayName'] as String?,
|
||||
customBreedName: json['customBreedName'] as String?,
|
||||
sex: PetSex.fromJson(json['sex'] as String),
|
||||
birthDate: _dateOrNull(json['birthDate']),
|
||||
birthDateEstimated: json['birthDateEstimated'] as bool,
|
||||
personality: json['personality'] as String?,
|
||||
microchipNo: json['microchipNo'] as String?,
|
||||
sterilizedOn: _dateOrNull(json['sterilizedOn']),
|
||||
status: PetStatus.fromJson(json['status'] as String),
|
||||
myRole: PetRole.fromJson(json['myRole'] as String),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
version: json['version'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final PetSpecies species;
|
||||
final String? breedId;
|
||||
final String? breedDisplayName;
|
||||
final String? customBreedName;
|
||||
final PetSex sex;
|
||||
final DateTime? birthDate;
|
||||
final bool birthDateEstimated;
|
||||
final String? personality;
|
||||
final String? microchipNo;
|
||||
final DateTime? sterilizedOn;
|
||||
final PetStatus status;
|
||||
final PetRole myRole;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final int version;
|
||||
}
|
||||
|
||||
/// 创建宠物请求。breedId 与 customBreedName 二选一且互斥(400/40000 兜底在服务端)。
|
||||
class CreatePetRequest {
|
||||
const CreatePetRequest({
|
||||
required this.name,
|
||||
required this.species,
|
||||
required this.sex,
|
||||
this.breedId,
|
||||
this.customBreedName,
|
||||
this.birthDate,
|
||||
this.birthDateEstimated,
|
||||
this.personality,
|
||||
this.microchipNo,
|
||||
this.sterilizedOn,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final PetSpecies species;
|
||||
final PetSex sex;
|
||||
final String? breedId;
|
||||
final String? customBreedName;
|
||||
final DateTime? birthDate;
|
||||
final bool? birthDateEstimated;
|
||||
final String? personality;
|
||||
final String? microchipNo;
|
||||
final DateTime? sterilizedOn;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'name': name,
|
||||
'species': species.name,
|
||||
'sex': sex.name,
|
||||
if (breedId != null) 'breedId': breedId,
|
||||
if (customBreedName != null) 'customBreedName': customBreedName,
|
||||
if (birthDate != null) 'birthDate': dateToJson(birthDate!),
|
||||
if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated,
|
||||
if (personality != null) 'personality': personality,
|
||||
if (microchipNo != null) 'microchipNo': microchipNo,
|
||||
if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!),
|
||||
};
|
||||
}
|
||||
|
||||
/// 更新宠物请求(部分更新:缺席字段不变;不支持清空回 null;
|
||||
/// 品种对整体替换;species 不可改;version 乐观锁必填)。
|
||||
class UpdatePetRequest {
|
||||
const UpdatePetRequest({
|
||||
required this.version,
|
||||
this.name,
|
||||
this.breedId,
|
||||
this.customBreedName,
|
||||
this.sex,
|
||||
this.birthDate,
|
||||
this.birthDateEstimated,
|
||||
this.personality,
|
||||
this.microchipNo,
|
||||
this.sterilizedOn,
|
||||
this.status,
|
||||
});
|
||||
|
||||
final int version;
|
||||
final String? name;
|
||||
final String? breedId;
|
||||
final String? customBreedName;
|
||||
final PetSex? sex;
|
||||
final DateTime? birthDate;
|
||||
final bool? birthDateEstimated;
|
||||
final String? personality;
|
||||
final String? microchipNo;
|
||||
final DateTime? sterilizedOn;
|
||||
final PetStatus? status;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'version': version,
|
||||
if (name != null) 'name': name,
|
||||
if (breedId != null) 'breedId': breedId,
|
||||
if (customBreedName != null) 'customBreedName': customBreedName,
|
||||
if (sex != null) 'sex': sex!.name,
|
||||
if (birthDate != null) 'birthDate': dateToJson(birthDate!),
|
||||
if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated,
|
||||
if (personality != null) 'personality': personality,
|
||||
if (microchipNo != null) 'microchipNo': microchipNo,
|
||||
if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!),
|
||||
if (status != null) 'status': status!.name,
|
||||
};
|
||||
}
|
||||
|
||||
/// 品种目录项(只读字典)。
|
||||
class Breed {
|
||||
const Breed({
|
||||
required this.id,
|
||||
required this.species,
|
||||
required this.code,
|
||||
required this.displayName,
|
||||
});
|
||||
|
||||
factory Breed.fromJson(Map<String, dynamic> json) {
|
||||
return Breed(
|
||||
id: json['id'] as String,
|
||||
species: PetSpecies.fromJson(json['species'] as String),
|
||||
code: json['code'] as String,
|
||||
displayName: json['displayName'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final PetSpecies species;
|
||||
final String code;
|
||||
final String displayName;
|
||||
}
|
||||
|
||||
/// 体重记录(append-only,无乐观锁)。
|
||||
class WeightRecord {
|
||||
const WeightRecord({
|
||||
required this.id,
|
||||
required this.petId,
|
||||
required this.weightKg,
|
||||
required this.measuredAt,
|
||||
required this.source,
|
||||
required this.note,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory WeightRecord.fromJson(Map<String, dynamic> json) {
|
||||
return WeightRecord(
|
||||
id: json['id'] as String,
|
||||
petId: json['petId'] as String,
|
||||
// 服务端 numeric(6,2),整数值也可能以 int 下发。
|
||||
weightKg: (json['weightKg'] as num).toDouble(),
|
||||
measuredAt: DateTime.parse(json['measuredAt'] as String),
|
||||
source: WeightSource.fromJson(json['source'] as String),
|
||||
note: json['note'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String petId;
|
||||
final double weightKg;
|
||||
final DateTime measuredAt;
|
||||
final WeightSource source;
|
||||
final String? note;
|
||||
final DateTime createdAt;
|
||||
}
|
||||
|
||||
/// 添加体重记录请求。
|
||||
class CreateWeightRequest {
|
||||
const CreateWeightRequest({
|
||||
required this.weightKg,
|
||||
required this.measuredAt,
|
||||
this.source,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final double weightKg;
|
||||
final DateTime measuredAt;
|
||||
final WeightSource? source;
|
||||
final String? note;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'weightKg': weightKg,
|
||||
'measuredAt': measuredAt.toIso8601String(),
|
||||
if (source != null) 'source': source!.name,
|
||||
if (note != null) 'note': note,
|
||||
};
|
||||
}
|
||||
|
||||
/// 疫苗目录项(只读字典)。
|
||||
class VaccineCatalogItem {
|
||||
const VaccineCatalogItem({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.species,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
factory VaccineCatalogItem.fromJson(Map<String, dynamic> json) {
|
||||
return VaccineCatalogItem(
|
||||
id: json['id'] as String,
|
||||
code: json['code'] as String,
|
||||
name: json['name'] as String,
|
||||
species: PetSpecies.fromJson(json['species'] as String),
|
||||
description: json['description'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String code;
|
||||
final String name;
|
||||
final PetSpecies species;
|
||||
final String? description;
|
||||
}
|
||||
|
||||
/// 疫苗记录。vaccineName 由目录解出(列表页免二次查字典)。
|
||||
class Vaccination {
|
||||
const Vaccination({
|
||||
required this.id,
|
||||
required this.petId,
|
||||
required this.vaccineId,
|
||||
required this.vaccineName,
|
||||
required this.seriesKey,
|
||||
required this.doseNo,
|
||||
required this.doseLabel,
|
||||
required this.status,
|
||||
required this.plannedOn,
|
||||
required this.administeredOn,
|
||||
required this.nextDueOn,
|
||||
required this.manufacturer,
|
||||
required this.batchNo,
|
||||
required this.notes,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.version,
|
||||
});
|
||||
|
||||
factory Vaccination.fromJson(Map<String, dynamic> json) {
|
||||
return Vaccination(
|
||||
id: json['id'] as String,
|
||||
petId: json['petId'] as String,
|
||||
vaccineId: json['vaccineId'] as String,
|
||||
vaccineName: json['vaccineName'] as String,
|
||||
seriesKey: json['seriesKey'] as String,
|
||||
doseNo: json['doseNo'] as int,
|
||||
doseLabel: json['doseLabel'] as String?,
|
||||
status: VaccinationStatus.fromJson(json['status'] as String),
|
||||
plannedOn: _dateOrNull(json['plannedOn']),
|
||||
administeredOn: _dateOrNull(json['administeredOn']),
|
||||
nextDueOn: _dateOrNull(json['nextDueOn']),
|
||||
manufacturer: json['manufacturer'] as String?,
|
||||
batchNo: json['batchNo'] as String?,
|
||||
notes: json['notes'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
version: json['version'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String petId;
|
||||
final String vaccineId;
|
||||
final String vaccineName;
|
||||
final String seriesKey;
|
||||
final int doseNo;
|
||||
final String? doseLabel;
|
||||
final VaccinationStatus status;
|
||||
final DateTime? plannedOn;
|
||||
final DateTime? administeredOn;
|
||||
final DateTime? nextDueOn;
|
||||
final String? manufacturer;
|
||||
final String? batchNo;
|
||||
final String? notes;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final int version;
|
||||
}
|
||||
|
||||
/// 创建疫苗记录请求(创建状态仅 scheduled / completed)。
|
||||
class CreateVaccinationRequest {
|
||||
const CreateVaccinationRequest({
|
||||
required this.vaccineId,
|
||||
required this.seriesKey,
|
||||
required this.doseNo,
|
||||
required this.status,
|
||||
this.doseLabel,
|
||||
this.plannedOn,
|
||||
this.administeredOn,
|
||||
this.nextDueOn,
|
||||
this.manufacturer,
|
||||
this.batchNo,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String vaccineId;
|
||||
final String seriesKey;
|
||||
final int doseNo;
|
||||
|
||||
/// 契约允许 scheduled / completed(cancelled 创建无业务意义,服务端 400)。
|
||||
final VaccinationStatus status;
|
||||
final String? doseLabel;
|
||||
final DateTime? plannedOn;
|
||||
final DateTime? administeredOn;
|
||||
final DateTime? nextDueOn;
|
||||
final String? manufacturer;
|
||||
final String? batchNo;
|
||||
final String? notes;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'vaccineId': vaccineId,
|
||||
'seriesKey': seriesKey,
|
||||
'doseNo': doseNo,
|
||||
'status': status.name,
|
||||
if (doseLabel != null) 'doseLabel': doseLabel,
|
||||
if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!),
|
||||
if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!),
|
||||
if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!),
|
||||
if (manufacturer != null) 'manufacturer': manufacturer,
|
||||
if (batchNo != null) 'batchNo': batchNo,
|
||||
if (notes != null) 'notes': notes,
|
||||
};
|
||||
}
|
||||
|
||||
/// 更新疫苗记录请求(vaccineId / seriesKey / doseNo 不可改;version 必填)。
|
||||
class UpdateVaccinationRequest {
|
||||
const UpdateVaccinationRequest({
|
||||
required this.version,
|
||||
this.status,
|
||||
this.plannedOn,
|
||||
this.administeredOn,
|
||||
this.nextDueOn,
|
||||
this.doseLabel,
|
||||
this.manufacturer,
|
||||
this.batchNo,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final int version;
|
||||
final VaccinationStatus? status;
|
||||
final DateTime? plannedOn;
|
||||
final DateTime? administeredOn;
|
||||
final DateTime? nextDueOn;
|
||||
final String? doseLabel;
|
||||
final String? manufacturer;
|
||||
final String? batchNo;
|
||||
final String? notes;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'version': version,
|
||||
if (status != null) 'status': status!.name,
|
||||
if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!),
|
||||
if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!),
|
||||
if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!),
|
||||
if (doseLabel != null) 'doseLabel': doseLabel,
|
||||
if (manufacturer != null) 'manufacturer': manufacturer,
|
||||
if (batchNo != null) 'batchNo': batchNo,
|
||||
if (notes != null) 'notes': notes,
|
||||
};
|
||||
}
|
||||
|
||||
/// 健康事件(六类;金额整数分传输,元/分换算留给 UI 层)。
|
||||
class HealthEvent {
|
||||
const HealthEvent({
|
||||
required this.id,
|
||||
required this.petId,
|
||||
required this.eventType,
|
||||
required this.occurredAt,
|
||||
required this.title,
|
||||
required this.notes,
|
||||
required this.amountCents,
|
||||
required this.createdByUserId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.version,
|
||||
});
|
||||
|
||||
factory HealthEvent.fromJson(Map<String, dynamic> json) {
|
||||
return HealthEvent(
|
||||
id: json['id'] as String,
|
||||
petId: json['petId'] as String,
|
||||
eventType: HealthEventType.fromJson(json['eventType'] as String),
|
||||
occurredAt: DateTime.parse(json['occurredAt'] as String),
|
||||
title: json['title'] as String,
|
||||
notes: json['notes'] as String?,
|
||||
amountCents: json['amountCents'] as int?,
|
||||
createdByUserId: json['createdByUserId'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
version: json['version'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String petId;
|
||||
final HealthEventType eventType;
|
||||
final DateTime occurredAt;
|
||||
final String title;
|
||||
final String? notes;
|
||||
|
||||
/// 金额(整数分,非负,可空)。DTO 层保持整数分不换算。
|
||||
final int? amountCents;
|
||||
final String createdByUserId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final int version;
|
||||
}
|
||||
|
||||
/// 添加健康事件请求(createdByUserId 取自 token,不收请求体)。
|
||||
class CreateHealthEventRequest {
|
||||
const CreateHealthEventRequest({
|
||||
required this.eventType,
|
||||
required this.occurredAt,
|
||||
required this.title,
|
||||
this.notes,
|
||||
this.amountCents,
|
||||
});
|
||||
|
||||
final HealthEventType eventType;
|
||||
final DateTime occurredAt;
|
||||
final String title;
|
||||
final String? notes;
|
||||
|
||||
/// 整数分;提交小数服务端 400/40000(不做静默截断)。
|
||||
final int? amountCents;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'eventType': eventType.name,
|
||||
'occurredAt': occurredAt.toIso8601String(),
|
||||
'title': title,
|
||||
if (notes != null) 'notes': notes,
|
||||
if (amountCents != null) 'amountCents': amountCents,
|
||||
};
|
||||
}
|
||||
|
||||
/// 更新健康事件请求(仅 title / notes / amountCents 可编辑;version 必填)。
|
||||
class UpdateHealthEventRequest {
|
||||
const UpdateHealthEventRequest({
|
||||
required this.version,
|
||||
this.title,
|
||||
this.notes,
|
||||
this.amountCents,
|
||||
});
|
||||
|
||||
final int version;
|
||||
final String? title;
|
||||
final String? notes;
|
||||
final int? amountCents;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'version': version,
|
||||
if (title != null) 'title': title,
|
||||
if (notes != null) 'notes': notes,
|
||||
if (amountCents != null) 'amountCents': amountCents,
|
||||
};
|
||||
}
|
||||
|
||||
/// 照护提醒(无 version 字段;completedAt 非空当且仅当 status=completed)。
|
||||
class CareReminder {
|
||||
const CareReminder({
|
||||
required this.id,
|
||||
required this.petId,
|
||||
required this.reminderType,
|
||||
required this.title,
|
||||
required this.dueAt,
|
||||
required this.status,
|
||||
required this.completedAt,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory CareReminder.fromJson(Map<String, dynamic> json) {
|
||||
return CareReminder(
|
||||
id: json['id'] as String,
|
||||
petId: json['petId'] as String,
|
||||
reminderType: CareReminderType.fromJson(json['reminderType'] as String),
|
||||
title: json['title'] as String,
|
||||
dueAt: DateTime.parse(json['dueAt'] as String),
|
||||
status: CareReminderStatus.fromJson(json['status'] as String),
|
||||
completedAt: json['completedAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['completedAt'] as String),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String petId;
|
||||
final CareReminderType reminderType;
|
||||
final String title;
|
||||
final DateTime dueAt;
|
||||
final CareReminderStatus status;
|
||||
final DateTime? completedAt;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
}
|
||||
|
||||
/// 创建照护提醒请求(创建恒为 pending,不收 status)。
|
||||
class CreateCareReminderRequest {
|
||||
const CreateCareReminderRequest({
|
||||
required this.reminderType,
|
||||
required this.title,
|
||||
required this.dueAt,
|
||||
});
|
||||
|
||||
final CareReminderType reminderType;
|
||||
final String title;
|
||||
final DateTime dueAt;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'reminderType': reminderType.name,
|
||||
'title': title,
|
||||
'dueAt': dueAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 更新提醒状态请求(状态流转专用:仅 status + completedAt)。
|
||||
class UpdateCareReminderRequest {
|
||||
const UpdateCareReminderRequest({required this.status, this.completedAt});
|
||||
|
||||
final CareReminderStatus status;
|
||||
|
||||
/// status=completed 时必填;其余状态禁带(违反 422/42202)。
|
||||
/// 由客户端提交(而非服务端 now()),允许补记实际完成时刻。
|
||||
final DateTime? completedAt;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'status': status.name,
|
||||
if (completedAt != null) 'completedAt': completedAt!.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 摘要:最新体重(无记录 → 整体 null)。
|
||||
class SummaryLatestWeight {
|
||||
const SummaryLatestWeight({required this.weightKg, required this.measuredAt});
|
||||
|
||||
factory SummaryLatestWeight.fromJson(Map<String, dynamic> json) {
|
||||
return SummaryLatestWeight(
|
||||
weightKg: (json['weightKg'] as num).toDouble(),
|
||||
measuredAt: DateTime.parse(json['measuredAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
final double weightKg;
|
||||
final DateTime measuredAt;
|
||||
}
|
||||
|
||||
/// 摘要:疫苗进度(totalDoses=0 → 整体 null,不是 0/0)。
|
||||
class SummaryVaccinationProgress {
|
||||
const SummaryVaccinationProgress({
|
||||
required this.completedDoses,
|
||||
required this.totalDoses,
|
||||
});
|
||||
|
||||
factory SummaryVaccinationProgress.fromJson(Map<String, dynamic> json) {
|
||||
return SummaryVaccinationProgress(
|
||||
completedDoses: json['completedDoses'] as int,
|
||||
totalDoses: json['totalDoses'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
final int completedDoses;
|
||||
final int totalDoses;
|
||||
}
|
||||
|
||||
/// 摘要:下次接种(候选集空 → 整体 null;dueOn 可为过去日期——逾期针仍是下一针)。
|
||||
class SummaryNextVaccination {
|
||||
const SummaryNextVaccination({
|
||||
required this.vaccinationId,
|
||||
required this.vaccineId,
|
||||
required this.vaccineName,
|
||||
required this.doseNo,
|
||||
required this.doseLabel,
|
||||
required this.dueOn,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
factory SummaryNextVaccination.fromJson(Map<String, dynamic> json) {
|
||||
return SummaryNextVaccination(
|
||||
vaccinationId: json['vaccinationId'] as String,
|
||||
vaccineId: json['vaccineId'] as String,
|
||||
vaccineName: json['vaccineName'] as String,
|
||||
doseNo: json['doseNo'] as int,
|
||||
doseLabel: json['doseLabel'] as String?,
|
||||
dueOn: DateTime.parse(json['dueOn'] as String),
|
||||
source: NextVaccinationSource.fromJson(json['source'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
final String vaccinationId;
|
||||
final String vaccineId;
|
||||
final String vaccineName;
|
||||
final int doseNo;
|
||||
final String? doseLabel;
|
||||
final DateTime dueOn;
|
||||
final NextVaccinationSource source;
|
||||
}
|
||||
|
||||
/// 摘要:当月花费(恒非 null;无支出 amountCents=0;月边界随 tz 移动)。
|
||||
class SummaryMonthlyExpense {
|
||||
const SummaryMonthlyExpense({
|
||||
required this.month,
|
||||
required this.timezone,
|
||||
required this.amountCents,
|
||||
});
|
||||
|
||||
factory SummaryMonthlyExpense.fromJson(Map<String, dynamic> json) {
|
||||
return SummaryMonthlyExpense(
|
||||
month: json['month'] as String,
|
||||
timezone: json['timezone'] as String,
|
||||
amountCents: json['amountCents'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
/// ISO year-month(如 2026-09)。
|
||||
final String month;
|
||||
final String timezone;
|
||||
final int amountCents;
|
||||
}
|
||||
|
||||
/// 档案聚合摘要(四项聚合实时计算;前三项无记录时为 null,
|
||||
/// monthlyExpense 恒非 null——契约 PetSummary null 语义)。
|
||||
class PetSummary {
|
||||
const PetSummary({
|
||||
required this.petId,
|
||||
required this.latestWeight,
|
||||
required this.vaccinationProgress,
|
||||
required this.nextVaccination,
|
||||
required this.monthlyExpense,
|
||||
});
|
||||
|
||||
factory PetSummary.fromJson(Map<String, dynamic> json) {
|
||||
return PetSummary(
|
||||
petId: json['petId'] as String,
|
||||
latestWeight: json['latestWeight'] == null
|
||||
? null
|
||||
: SummaryLatestWeight.fromJson(
|
||||
json['latestWeight'] as Map<String, dynamic>,
|
||||
),
|
||||
vaccinationProgress: json['vaccinationProgress'] == null
|
||||
? null
|
||||
: SummaryVaccinationProgress.fromJson(
|
||||
json['vaccinationProgress'] as Map<String, dynamic>,
|
||||
),
|
||||
nextVaccination: json['nextVaccination'] == null
|
||||
? null
|
||||
: SummaryNextVaccination.fromJson(
|
||||
json['nextVaccination'] as Map<String, dynamic>,
|
||||
),
|
||||
monthlyExpense: SummaryMonthlyExpense.fromJson(
|
||||
json['monthlyExpense'] as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final String petId;
|
||||
final SummaryLatestWeight? latestWeight;
|
||||
final SummaryVaccinationProgress? vaccinationProgress;
|
||||
final SummaryNextVaccination? nextVaccination;
|
||||
final SummaryMonthlyExpense monthlyExpense;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||
|
||||
/// 网络数据四态(第 9 节硬要求:loading / empty / error / retry,
|
||||
/// data 态含空列表判定,页面据 [PetsLoadPhase] + 数据渲染四态)。
|
||||
enum PetsLoadPhase { initial, loading, ready, error }
|
||||
|
||||
/// pets feature 状态控制器(开发计划 §4.2 分层:
|
||||
/// Page/Widget → PetsController → PetsRepository → ApiClient)。
|
||||
///
|
||||
/// 宠物档案状态自此从 `AppState` 拆出:本 feature 不读写 AppState 的
|
||||
/// 宠物/疫苗 demo 数据;服务端是唯一事实来源,内存副本仅作展示缓存
|
||||
/// (无本地持久化——档案数据以 [refresh] 为失效/刷新策略)。
|
||||
///
|
||||
/// 本单只承载列表 + 详情主链路状态;体重/疫苗/事件/提醒的页面级
|
||||
/// 状态由 T2-12~14 按页面直接经 Repository 取数或扩展本控制器。
|
||||
class PetsController extends ChangeNotifier {
|
||||
PetsController({required this._repository});
|
||||
|
||||
final PetsRepository _repository;
|
||||
|
||||
PetsLoadPhase _phase = PetsLoadPhase.initial;
|
||||
List<Pet> _pets = const [];
|
||||
ApiException? _lastError;
|
||||
bool _disposed = false;
|
||||
|
||||
PetsLoadPhase get phase => _phase;
|
||||
|
||||
/// 当前用户可见宠物(服务端 created_at DESC 排序原样保留)。
|
||||
List<Pet> get pets => _pets;
|
||||
|
||||
/// ready 且列表为空 → 空态(新用户建档引导)。
|
||||
bool get isEmpty => _phase == PetsLoadPhase.ready && _pets.isEmpty;
|
||||
|
||||
/// 最近一次加载失败的类型化错误(error 态时非 null)。
|
||||
ApiException? get lastError => _lastError;
|
||||
|
||||
/// 加载 / 重试宠物列表。错误不外抛,收敛为 error 态供页面渲染 + retry。
|
||||
Future<void> refresh() async {
|
||||
_phase = PetsLoadPhase.loading;
|
||||
_lastError = null;
|
||||
_notify();
|
||||
try {
|
||||
_pets = await _repository.listPets();
|
||||
_phase = PetsLoadPhase.ready;
|
||||
} on ApiException catch (error) {
|
||||
_lastError = error;
|
||||
_phase = PetsLoadPhase.error;
|
||||
}
|
||||
_notify();
|
||||
}
|
||||
|
||||
/// 创建宠物:成功后就地插入列表头(服务端 created_at DESC,新建最前),
|
||||
/// 失败按类型化异常外抛给表单层处理(40903 芯片号冲突等)。
|
||||
Future<Pet> createPet(CreatePetRequest request) async {
|
||||
final pet = await _repository.createPet(request);
|
||||
_pets = [pet, ..._pets];
|
||||
if (_phase != PetsLoadPhase.ready) _phase = PetsLoadPhase.ready;
|
||||
_notify();
|
||||
return pet;
|
||||
}
|
||||
|
||||
/// 拉取单只宠物详情并同步列表内存副本。
|
||||
Future<Pet> getPet(String petId) async {
|
||||
final pet = await _repository.getPet(petId);
|
||||
_replaceInList(pet);
|
||||
return pet;
|
||||
}
|
||||
|
||||
/// 更新宠物档案:成功后同步列表副本;40902 版本冲突等外抛,
|
||||
/// 页面提示后应调用 [getPet] 刷新取新 version 重提。
|
||||
Future<Pet> updatePet(String petId, UpdatePetRequest request) async {
|
||||
final pet = await _repository.updatePet(petId, request);
|
||||
_replaceInList(pet);
|
||||
return pet;
|
||||
}
|
||||
|
||||
void _replaceInList(Pet pet) {
|
||||
final index = _pets.indexWhere((item) => item.id == pet.id);
|
||||
if (index == -1) return;
|
||||
_pets = [..._pets]..[index] = pet;
|
||||
_notify();
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||
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_models.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// pets 域仓库接口(契约 v1.2.0 的 12 路径 / 18 操作全覆盖;
|
||||
/// 页面依赖此抽象,widget 测试注入假实现)。
|
||||
abstract class PetsRepository {
|
||||
// ---- 宠物 CRUD ----
|
||||
Future<List<Pet>> listPets();
|
||||
Future<Pet> createPet(CreatePetRequest request);
|
||||
Future<Pet> getPet(String petId);
|
||||
Future<Pet> updatePet(String petId, UpdatePetRequest request);
|
||||
|
||||
// ---- 字典 ----
|
||||
Future<List<Breed>> listBreeds({PetSpecies? species});
|
||||
Future<List<VaccineCatalogItem>> listVaccineCatalog({PetSpecies? species});
|
||||
|
||||
// ---- 体重(cursor 分页)----
|
||||
Future<CursorPage<WeightRecord>> listWeights(
|
||||
String petId, {
|
||||
int? limit,
|
||||
String? cursor,
|
||||
});
|
||||
Future<WeightRecord> createWeight(String petId, CreateWeightRequest request);
|
||||
|
||||
// ---- 疫苗 ----
|
||||
Future<List<Vaccination>> listVaccinations(String petId);
|
||||
Future<Vaccination> createVaccination(
|
||||
String petId,
|
||||
CreateVaccinationRequest request,
|
||||
);
|
||||
Future<Vaccination> updateVaccination(
|
||||
String vaccinationId,
|
||||
UpdateVaccinationRequest request,
|
||||
);
|
||||
|
||||
// ---- 健康事件(cursor 分页)----
|
||||
Future<CursorPage<HealthEvent>> listHealthEvents(
|
||||
String petId, {
|
||||
int? limit,
|
||||
String? cursor,
|
||||
});
|
||||
Future<HealthEvent> createHealthEvent(
|
||||
String petId,
|
||||
CreateHealthEventRequest request,
|
||||
);
|
||||
Future<HealthEvent> updateHealthEvent(
|
||||
String eventId,
|
||||
UpdateHealthEventRequest request,
|
||||
);
|
||||
|
||||
// ---- 照护提醒 ----
|
||||
Future<List<CareReminder>> listCareReminders(
|
||||
String petId, {
|
||||
CareReminderStatus? status,
|
||||
});
|
||||
Future<CareReminder> createCareReminder(
|
||||
String petId,
|
||||
CreateCareReminderRequest request,
|
||||
);
|
||||
Future<CareReminder> updateCareReminder(
|
||||
String reminderId,
|
||||
UpdateCareReminderRequest request,
|
||||
);
|
||||
|
||||
// ---- 聚合摘要 ----
|
||||
Future<PetSummary> getPetSummary(String petId, {String? tz});
|
||||
}
|
||||
|
||||
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
|
||||
/// 拦截 + 401/40101 单飞刷新重放);pets 域业务错误码升格为类型化异常。
|
||||
///
|
||||
/// 幂等:weights / vaccinations / health-events / care-reminders 四个 POST
|
||||
/// 按契约携带可选 `Idempotency-Key`(每次逻辑提交换新键;token 刷新后的
|
||||
/// 自动重放沿用同一个键,与 auth register 先例一致)。
|
||||
class ApiPetsRepository implements PetsRepository {
|
||||
ApiPetsRepository({required this._api, this._uuid = const Uuid()});
|
||||
|
||||
final ApiClient _api;
|
||||
final Uuid _uuid;
|
||||
|
||||
Future<Object?> _request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
Object? body,
|
||||
Map<String, Object?>? query,
|
||||
bool idempotent = false,
|
||||
}) async {
|
||||
try {
|
||||
return await _api.request(
|
||||
path,
|
||||
method: method,
|
||||
body: body,
|
||||
query: query,
|
||||
headers: idempotent ? {'Idempotency-Key': _uuid.v4()} : null,
|
||||
requiresAuth: true,
|
||||
);
|
||||
} on ApiBusinessException catch (error) {
|
||||
throw mapPetBusinessException(error);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _asMap(Object? data) => data! as Map<String, dynamic>;
|
||||
|
||||
List<T> _asList<T>(Object? data, T Function(Map<String, dynamic>) fromJson) {
|
||||
return (data! as List)
|
||||
.map((item) => fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Pet>> listPets() async {
|
||||
final data = await _request('/api/v1/pets');
|
||||
return _asList(data, Pet.fromJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Pet> createPet(CreatePetRequest request) async {
|
||||
final data = await _request(
|
||||
'/api/v1/pets',
|
||||
method: 'POST',
|
||||
body: request.toJson(),
|
||||
);
|
||||
return Pet.fromJson(_asMap(data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Pet> getPet(String petId) async {
|
||||
final data = await _request('/api/v1/pets/$petId');
|
||||
return Pet.fromJson(_asMap(data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Pet> updatePet(String petId, UpdatePetRequest request) async {
|
||||
final data = await _request(
|
||||
'/api/v1/pets/$petId',
|
||||
method: 'PATCH',
|
||||
body: request.toJson(),
|
||||
);
|
||||
return Pet.fromJson(_asMap(data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Breed>> listBreeds({PetSpecies? species}) async {
|
||||
final data = await _request(
|
||||
'/api/v1/breeds',
|
||||
query: {if (species != null) 'species': species.name},
|
||||
);
|
||||
return _asList(data, Breed.fromJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<VaccineCatalogItem>> listVaccineCatalog({
|
||||
PetSpecies? species,
|
||||
}) async {
|
||||
final data = await _request(
|
||||
'/api/v1/vaccine-catalog',
|
||||
query: {if (species != null) 'species': species.name},
|
||||
);
|
||||
return _asList(data, VaccineCatalogItem.fromJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CursorPage<WeightRecord>> listWeights(
|
||||
String petId, {
|
||||
int? limit,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final data = await _request(
|
||||
'/api/v1/pets/$petId/weights',
|
||||
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||
);
|
||||
return CursorPage.fromJson(_asMap(data), WeightRecord.fromJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WeightRecord> createWeight(
|
||||
String petId,
|
||||
CreateWeightRequest request,
|
||||
) async {
|
||||
final data = await _request(
|
||||
'/api/v1/pets/$petId/weights',
|
||||
method: 'POST',
|
||||
body: request.toJson(),
|
||||
idempotent: true,
|
||||
);
|
||||
return WeightRecord.fromJson(_asMap(data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Vaccination>> listVaccinations(String petId) async {
|
||||
final data = await _request('/api/v1/pets/$petId/vaccinations');
|
||||
return _asList(data, Vaccination.fromJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Vaccination> createVaccination(
|
||||
String petId,
|
||||
CreateVaccinationRequest request,
|
||||
) async {
|
||||
final data = await _request(
|
||||
'/api/v1/pets/$petId/vaccinations',
|
||||
method: 'POST',
|
||||
body: request.toJson(),
|
||||
idempotent: true,
|
||||
);
|
||||
return Vaccination.fromJson(_asMap(data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Vaccination> updateVaccination(
|
||||
String vaccinationId,
|
||||
UpdateVaccinationRequest request,
|
||||
) async {
|
||||
final data = await _request(
|
||||
'/api/v1/vaccinations/$vaccinationId',
|
||||
method: 'PATCH',
|
||||
body: request.toJson(),
|
||||
);
|
||||
return Vaccination.fromJson(_asMap(data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CursorPage<HealthEvent>> listHealthEvents(
|
||||
String petId, {
|
||||
int? limit,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final data = await _request(
|
||||
'/api/v1/pets/$petId/health-events',
|
||||
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||
);
|
||||
return CursorPage.fromJson(_asMap(data), HealthEvent.fromJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HealthEvent> createHealthEvent(
|
||||
String petId,
|
||||
CreateHealthEventRequest request,
|
||||
) async {
|
||||
final data = await _request(
|
||||
'/api/v1/pets/$petId/health-events',
|
||||
method: 'POST',
|
||||
body: request.toJson(),
|
||||
idempotent: true,
|
||||
);
|
||||
return HealthEvent.fromJson(_asMap(data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HealthEvent> updateHealthEvent(
|
||||
String eventId,
|
||||
UpdateHealthEventRequest request,
|
||||
) async {
|
||||
final data = await _request(
|
||||
'/api/v1/health-events/$eventId',
|
||||
method: 'PATCH',
|
||||
body: request.toJson(),
|
||||
);
|
||||
return HealthEvent.fromJson(_asMap(data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CareReminder>> listCareReminders(
|
||||
String petId, {
|
||||
CareReminderStatus? status,
|
||||
}) async {
|
||||
final data = await _request(
|
||||
'/api/v1/pets/$petId/care-reminders',
|
||||
query: {if (status != null) 'status': status.name},
|
||||
);
|
||||
return _asList(data, CareReminder.fromJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CareReminder> createCareReminder(
|
||||
String petId,
|
||||
CreateCareReminderRequest request,
|
||||
) async {
|
||||
final data = await _request(
|
||||
'/api/v1/pets/$petId/care-reminders',
|
||||
method: 'POST',
|
||||
body: request.toJson(),
|
||||
idempotent: true,
|
||||
);
|
||||
return CareReminder.fromJson(_asMap(data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CareReminder> updateCareReminder(
|
||||
String reminderId,
|
||||
UpdateCareReminderRequest request,
|
||||
) async {
|
||||
final data = await _request(
|
||||
'/api/v1/care-reminders/$reminderId',
|
||||
method: 'PATCH',
|
||||
body: request.toJson(),
|
||||
);
|
||||
return CareReminder.fromJson(_asMap(data));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PetSummary> getPetSummary(String petId, {String? tz}) async {
|
||||
final data = await _request(
|
||||
'/api/v1/pets/$petId/summary',
|
||||
query: {'tz': ?tz},
|
||||
);
|
||||
return PetSummary.fromJson(_asMap(data));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user