feat(flutter): 반려동물 등록·수정 화면과 기기 저장소 구현

시안 screen-register · pet-species 를 옮겼다.
- 사진(앨범 선택 → 앱 폴더로 복사), 이름, 종·품종 검색 시트(12 대분류), 성별·중성화,
  생년월일 휠, 몸무게(kg/g), 건강 정보, 알레르기(직접 추가 항목은 다음 등록에도 유지), 메모
- 필수값이 빠지면 칸마다 오류를 표시하고 첫 빠진 칸으로 스크롤
- 수정 모드에서 삭제(확인창) 지원
- 저장은 PetRepository 계약 뒤의 LocalPetRepository(SharedPreferences JSON) —
  백엔드 /api/pets 가 생기면 Dio 구현으로 바꾼다
- image_picker · path_provider 추가, iOS 사진 접근 문구(NSPhotoLibraryUsageDescription)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:35:39 +09:00
parent 544281ed8e
commit 50d1aeea4b
42 changed files with 2023 additions and 4 deletions
@@ -0,0 +1,32 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
const String _kPhotoDir = 'pet_photos';
/// 앨범에서 고른 사진을 앱 전용 폴더로 복사해 경로를 돌려준다.
/// (앨범 원본이 지워져도 아이 사진이 남도록) — 서버 업로드가 생기면 그쪽으로 바꾼다.
class PetPhotoStore {
const PetPhotoStore();
Future<String> persist(XFile file) async {
// 웹은 미리보기용 — 브라우저가 준 임시 주소를 그대로 쓴다
if (kIsWeb) return file.path;
final docs = await getApplicationDocumentsDirectory();
final dir = Directory('${docs.path}/$_kPhotoDir');
if (!dir.existsSync()) await dir.create(recursive: true);
final dot = file.name.lastIndexOf('.');
final ext = dot >= 0 ? file.name.substring(dot) : '.jpg';
// 저장 파일명은 앱이 만든다 — 원본 이름을 경로에 쓰지 않는다
final target = '${dir.path}/${DateTime.now().microsecondsSinceEpoch}$ext';
await file.saveTo(target);
return target;
}
}
final petPhotoStoreProvider = Provider<PetPhotoStore>((ref) => const PetPhotoStore());
@@ -0,0 +1,28 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
/// 기본 알레르기 항목 (시안 ALLERGENS)
const List<String> kDefaultAllergens = [
'닭고기', '소고기', '돼지고기', '유제품', '밀', '옥수수', '계란', '생선', '콩',
];
const String _kCustomAllergensKey = 'pet.customAllergens';
/// 사용자가 "직접 추가" 한 알레르기 항목 — 다음 등록 때도 메뉴에 남는다.
class AllergenMenuRepository {
AllergenMenuRepository(this._prefs);
final SharedPreferences _prefs;
List<String> load() => _prefs.getStringList(_kCustomAllergensKey) ?? const [];
Future<void> save(List<String> items) => _prefs.setStringList(
_kCustomAllergensKey,
items.where((a) => !kDefaultAllergens.contains(a)).toList(),
);
}
final allergenMenuRepositoryProvider = Provider<AllergenMenuRepository>((ref) {
return AllergenMenuRepository(ref.watch(sharedPreferencesProvider));
});
@@ -0,0 +1,65 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
/// 반려동물 저장소 계약 — 백엔드 API(/api/pets)가 생기면 Dio 구현으로 바꾼다.
abstract interface class PetRepository {
/// 사용자가 정한 순서대로
Future<List<Pet>> fetchPets();
Future<Pet> create(PetInput input);
Future<Pet> update(String id, PetInput input);
Future<void> delete(String id);
}
const String _kPetsKey = 'pets.v1';
/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다.
/// 서버 연동 전까지 앱을 다시 켜도 등록한 아이가 남도록 하기 위한 것이다.
class LocalPetRepository implements PetRepository {
LocalPetRepository(this._prefs, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now;
final SharedPreferences _prefs;
final DateTime Function() _clock;
List<Pet> _read() {
final raw = _prefs.getString(_kPetsKey);
if (raw == null) return [];
final list = jsonDecode(raw) as List<Object?>;
return list.whereType<Map<String, Object?>>().map(Pet.fromJson).toList();
}
Future<void> _write(List<Pet> pets) =>
_prefs.setString(_kPetsKey, jsonEncode([for (final p in pets) p.toJson()]));
@override
Future<List<Pet>> fetchPets() async => _read();
@override
Future<Pet> create(PetInput input) async {
final pet = Pet.fromInput('pet-${_clock().microsecondsSinceEpoch}', input);
await _write([..._read(), pet]);
return pet;
}
@override
Future<Pet> update(String id, PetInput input) async {
final pet = Pet.fromInput(id, input);
await _write([for (final p in _read()) p.id == id ? pet : p]);
return pet;
}
@override
Future<void> delete(String id) async {
await _write(_read().where((p) => p.id != id).toList());
}
}
final petRepositoryProvider = Provider<PetRepository>((ref) {
return LocalPetRepository(ref.watch(sharedPreferencesProvider));
});
+161
View File
@@ -0,0 +1,161 @@
import 'package:tma_app/core/utils/date_format.dart';
enum PetGender {
male('남아'),
female('여아');
const PetGender(this.label);
final String label;
}
enum WeightUnit {
kg('킬로그램 (kg)'),
g('그램 (g)');
const WeightUnit(this.label);
/// 단위 선택 시트에 보이는 이름
final String label;
}
/// 등록·수정 폼에서 받는 값 (서버가 id 를 붙이기 전)
class PetInput {
const PetInput({
required this.name,
required this.species,
required this.breed,
required this.gender,
required this.neutered,
required this.birthDate,
required this.weight,
required this.weightUnit,
this.photoPath,
this.bloodType,
this.vet,
this.memo,
this.allergies = const [],
this.conditions = const [],
this.watchConditions = const [],
});
final String name;
/// 대분류 — 강아지·고양이 … (PetCategory.id)
final String species;
/// 세부 품종 — 고르지 않았으면 빈 문자열
final String breed;
final PetGender gender;
final bool neutered;
final DateTime birthDate;
final double weight;
final WeightUnit weightUnit;
/// 기기에 저장한 사진 경로 — 없으면 기본 아이콘
final String? photoPath;
/// 값이 없으면 null ("미등록" 표기는 화면에서 한다)
final String? bloodType;
final String? vet;
final String? memo;
final List<String> allergies;
/// 현재 보유 질환
final List<String> conditions;
/// 염려되는 질환 (주의가 필요한 항목)
final List<String> watchConditions;
}
/// 등록된 반려동물
class Pet extends PetInput {
const Pet({
required this.id,
required super.name,
required super.species,
required super.breed,
required super.gender,
required super.neutered,
required super.birthDate,
required super.weight,
required super.weightUnit,
super.photoPath,
super.bloodType,
super.vet,
super.memo,
super.allergies,
super.conditions,
super.watchConditions,
});
factory Pet.fromInput(String id, PetInput input) => Pet(
id: id,
name: input.name,
species: input.species,
breed: input.breed,
gender: input.gender,
neutered: input.neutered,
birthDate: input.birthDate,
weight: input.weight,
weightUnit: input.weightUnit,
photoPath: input.photoPath,
bloodType: input.bloodType,
vet: input.vet,
memo: input.memo,
allergies: input.allergies,
conditions: input.conditions,
watchConditions: input.watchConditions,
);
final String id;
/// 3.2kg · 450g — 소수점 끝의 0 은 떼어 낸다
String get weightLabel {
final text = weight == weight.roundToDouble()
? weight.toStringAsFixed(0)
: weight.toString();
return '$text${weightUnit.name}';
}
Map<String, Object?> toJson() => {
'id': id,
'name': name,
'species': species,
'breed': breed,
'gender': gender.name,
'neutered': neutered,
'birthDate': toIsoDate(birthDate),
'weight': weight,
'weightUnit': weightUnit.name,
'photoPath': photoPath,
'bloodType': bloodType,
'vet': vet,
'memo': memo,
'allergies': allergies,
'conditions': conditions,
'watchConditions': watchConditions,
};
factory Pet.fromJson(Map<String, Object?> json) => Pet(
id: json['id']! as String,
name: json['name']! as String,
species: json['species']! as String,
breed: json['breed'] as String? ?? '',
gender: PetGender.values.byName(json['gender']! as String),
neutered: json['neutered']! as bool,
birthDate: parseIsoDate(json['birthDate']! as String),
weight: (json['weight']! as num).toDouble(),
weightUnit: WeightUnit.values.byName(json['weightUnit']! as String),
photoPath: json['photoPath'] as String?,
bloodType: json['bloodType'] as String?,
vet: json['vet'] as String?,
memo: json['memo'] as String?,
allergies: _stringList(json['allergies']),
conditions: _stringList(json['conditions']),
watchConditions: _stringList(json['watchConditions']),
);
}
List<String> _stringList(Object? value) =>
value is List ? value.whereType<String>().toList() : const [];
@@ -0,0 +1,10 @@
/// 생년월일로 나이 표기 — 1살 이상이면 "N살 M개월", 아니면 "M개월".
/// 이번 달의 생일 날짜가 아직 안 왔으면 한 달을 덜 센다 (시안 computeAge).
String petAgeLabel(DateTime birth, DateTime today) {
var months = (today.year - birth.year) * 12 + (today.month - birth.month);
if (today.day < birth.day) months--;
if (months < 0) months = 0;
final years = months ~/ 12;
final rest = months % 12;
return years >= 1 ? '$years살 $rest개월' : '$rest개월';
}
@@ -0,0 +1,90 @@
/// 반려동물 대분류 + 세부 품종 (시안 pet-species.jsx)
class PetCategory {
const PetCategory(this.id, this.iconKey, this.breeds);
/// 화면에 보이는 이름이자 저장값 — 강아지·고양이 …
final String id;
/// 실루엣 아이콘 파일 이름 (`assets/svg/species/{iconKey}.svg`)
final String iconKey;
/// 세부 품종 — 맨 앞에 [kUnknownBreed] 를 붙여서 보여 준다
final List<String> breeds;
/// 선택 목록 — 종을 모르는 경우를 맨 위에 둔다
List<String> get choices => [kUnknownBreed, ...breeds];
}
/// 종을 정확히 모르거나 잡종인 경우 — 모든 대분류 맨 위에 공통으로 둔다
const String kUnknownBreed = '나만의 아이 🩷 (종 모름/기타)';
/// 위 항목을 고르면 저장·표시되는 값 (괄호 설명은 고를 때만 보인다)
const String kUnknownBreedValue = '나만의 아이 🩷';
/// 고르면 세부 품종을 비워 두는 항목
const String kCustomBreed = '직접 입력';
const String kDefaultSpecies = '강아지';
/// 목록에서 고른 항목 → 저장값
String breedValueOf(String choice) => switch (choice) {
kCustomBreed => '',
kUnknownBreed => kUnknownBreedValue,
_ => choice,
};
const List<PetCategory> kPetCategories = [
PetCategory('강아지', 'dog', [
'말티즈', '푸들 (토이)', '푸들 (미니어처)', '푸들 (스탠다드)', '포메라니안', '시츄', '요크셔테리어', '치와와', '비숑 프리제',
'닥스훈트', '웰시코기 (펨브로크)', '웰시코기 (카디건)', '시바견', '진돗개', '풍산개', '삽살개', '파피용', '페키니즈', '라사압소',
'코통 드 툴레아', '재패니즈 스피츠', '폼피츠', '말티푸', '코카푸', '골든두들', '믹스견',
'비글', '코커 스패니얼', '스프링거 스패니얼', '보더콜리', '셰틀랜드 쉽독', '오스트레일리안 셰퍼드', '콜리',
'슈나우저 (미니어처)', '슈나우저 (스탠다드)', '슈나우저 (자이언트)', '프렌치 불독', '잉글리시 불독', '보스턴테리어', '퍼그',
'잭 러셀 테리어', '웨스트 하이랜드 화이트테리어', '스코티시테리어', '케언테리어', '불테리어', '폭스테리어', '에어데일테리어', '미니어처 핀셔',
'카발리에 킹 찰스 스패니얼', '달마시안', '차우차우', '아키타견',
'골든 리트리버', '래브라도 리트리버', '저먼 셰퍼드', '도베르만', '로트와일러', '복서', '시베리안 허스키', '알래스칸 말라뮤트',
'사모예드', '버니즈 마운틴독', '그레이트 피레니즈', '그레이트 데인', '세인트 버나드', '마스티프', '뉴펀들랜드', '티베탄 마스티프',
'그레이하운드', '휘핏', '보르조이', '아프간 하운드', '살루키', '바셋하운드', '블러드하운드', '와이마라너', '비즐라', '포인터', '바센지',
'아메리칸 핏불테리어', '아메리칸 불리', '스타포드셔 불테리어', '이탈리안 그레이하운드',
]),
PetCategory('고양이', 'cat', [
'코리안 숏헤어', '페르시안', '러시안 블루', '브리티시 숏헤어', '브리티시 롱헤어', '스코티시 폴드', '스코티시 스트레이트',
'먼치킨', '먼치킨 나폴레옹', '하이랜드 폴드', '아메리칸 숏헤어', '아메리칸 컬', '샴', '노르웨이 숲', '메인쿤', '랙돌', '벵갈', '아비시니안', '소말리',
'스핑크스', '피터볼드', '데본렉스', '코니시렉스', '셀커크렉스', '라팜', '터키시 앙고라', '터키시 반', '버만', '봄베이',
'샤트룩스', '싱가퓨라', '오시캣', '이그조틱 숏헤어', '히말라얀', '발리니즈', '통키니즈', '코랫', '맹크스', '시베리안',
'오리엔탈 숏헤어', '재패니즈 밥테일', '이집트 마우', '네벨룽', '토이거', '사바나', '하바나 브라운', '버미즈', '친칠라 (고양이)', '믹스묘',
]),
PetCategory('토끼', 'rabbit', [
'네덜란드 드워프', '홀랜드 롭', '미니 롭', '잉글리시 롭', '프렌치 롭', '라이언헤드', '렉스', '미니 렉스', '앙고라',
'저지 울리', '더치', '히말라얀 래빗', '친칠라 래빗', '폴리시', '플레미시 자이언트', '믹스 토끼',
]),
PetCategory('햄스터', 'hamster', [
'골든 햄스터 (시리안)', '테디베어 햄스터', '드워프 캠벨', '정글리안 (윈터화이트)', '로보로브스키', '차이니즈 햄스터',
]),
PetCategory('기니피그', 'guineapig', [
'아메리칸', '아비시니안', '페루비안', '셸티', '텍셀', '코로넷', '화이트 크레스티드', '스킨니 (무모)',
]),
PetCategory('페럿', 'ferret', ['세이블', '초콜릿', '시나몬', '알비노', '블랙 세이블', '패널 (다크아이드 화이트)']),
PetCategory('고슴도치', 'hedgehog', ['아프리칸 피그미', '솔트앤페퍼', '시나몬', '알비노', '초콜릿', '파이드']),
PetCategory('새', 'bird', [
'사랑앵무 (버지)', '왕관앵무 (코카티엘)', '모란앵무 (러브버드)', '회색앵무', '코뉴어', '뉴기니아 앵무', '로리키트',
'아마존앵무', '카이큐', '마카우 (금강앵무)', '카나리아', '문조', '금화조', '십자매', '잉꼬', '왕관비둘기',
]),
PetCategory('도마뱀', 'lizard', [
'레오파드 게코', '크레스티드 게코', '턱수염도마뱀 (비어디드 드래곤)', '토케이 게코', '자이언트 게코', '블루텅 스킨크',
'이구아나', '카멜레온', '사바나 모니터', '차이니즈 워터 드래곤', '우로마스틱스', '아르마딜로 도마뱀',
]),
PetCategory('거북이', 'turtle', [
'붉은귀거북', '노란배거북', '비단거북 (페인티드)', '사향거북 (머스크)', '지도거북', '늪거북',
'러시안 육지거북', '헤르만 육지거북', '설카타 육지거북', '별거북', '레오파드 육지거북',
]),
PetCategory('뱀', 'snake', ['콘스네이크', '볼파이톤', '밀크스네이크', '킹스네이크', '가터스네이크', '호그노즈', '보아뱀']),
PetCategory('물고기', 'fish', [
'베타', '구피', '금붕어', '네온테트라', '구라미', '몰리', '플래티', '엔젤피시', '디스커스', '코리도라스',
'플레코 (안시스트루스)', '시클리드', '아피스토그라마', '라스보라', '제브라다니오', '킬리피시', '잉어 (코이)', '아로와나',
]),
PetCategory('기타', 'other', ['슈가글라이더', '친칠라', '미니피그', '다람쥐', '거미 (타란튤라)', '달팽이', kCustomBreed]),
];
PetCategory categoryOf(String id) =>
kPetCategories.firstWhere((c) => c.id == id, orElse: () => kPetCategories.last);
@@ -0,0 +1,472 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/korean.dart';
import 'package:tma_app/features/pet/data/pet_photo_store.dart';
import 'package:tma_app/features/pet/data/repositories/allergen_menu_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_species.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/allergen_selector.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_form_parts.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_slot.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_pick_sheets.dart';
import 'package:tma_app/shared/widgets/date_picker_sheet.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_button.dart';
import 'package:tma_app/shared/widgets/rup_dialog.dart';
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
import 'package:tma_app/shared/widgets/rup_nav_header.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
/// 등록 화면 입력 항목 사이 간격 — 시안 .rup-reg .rup-field { margin-bottom: 22px }
const double _kFieldGap = 22;
const double _kColumnGap = 24;
/// 사진 선택 시 줄이는 크기 — 원본 그대로 두면 저장 공간을 많이 쓴다
const double _kPhotoMaxSide = 1600;
const int _kPhotoQuality = 85;
/// 빠진 칸으로 스크롤할 때 화면 위쪽 여유
const double _kScrollAlignment = .1;
/// 쉼표·가운뎃점으로 나눈 목록 ("피부염, 슬개골" → [피부염, 슬개골])
List<String> _splitList(String text) =>
text.split(RegExp('[,·、]')).map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
String? _nullIfBlank(String text) => text.trim().isEmpty ? null : text.trim();
/// 반려동물 등록 / 수정 — [petId] 가 있으면 수정 모드
class PetRegisterPage extends ConsumerStatefulWidget {
const PetRegisterPage({super.key, this.petId});
final String? petId;
@override
ConsumerState<PetRegisterPage> createState() => _PetRegisterPageState();
}
class _PetRegisterPageState extends ConsumerState<PetRegisterPage> {
late final Pet? _editing =
widget.petId == null ? null : ref.read(petByIdProvider(widget.petId!));
late final _name = TextEditingController(text: _editing?.name);
late final _weight = TextEditingController(
text: _editing == null ? '' : _editing.weightLabel.replaceAll(RegExp('[a-z]'), ''),
);
late final _conditions = TextEditingController(text: _editing?.conditions.join(', '));
late final _watch = TextEditingController(text: _editing?.watchConditions.join(', '));
late final _vet = TextEditingController(text: _editing?.vet);
late final _blood = TextEditingController(text: _editing?.bloodType);
late final _memo = TextEditingController(text: _editing?.memo);
late String _species = _editing?.species ?? kDefaultSpecies;
late String _breed = _editing?.breed ?? '';
late bool _speciesPicked = _editing != null;
late PetGender _gender = _editing?.gender ?? PetGender.male;
late bool _neutered = _editing?.neutered ?? true;
late DateTime? _birth = _editing?.birthDate;
late WeightUnit _unit = _editing?.weightUnit ?? WeightUnit.kg;
late String? _photoPath = _editing?.photoPath;
late final Set<String> _allergies = {...?_editing?.allergies};
late final List<String> _customMenu = _initialCustomMenu();
bool _showErrors = false;
bool _saving = false;
// 빠진 칸으로 스크롤하기 위한 위치 표식
final _nameKey = GlobalKey();
final _speciesKey = GlobalKey();
final _birthKey = GlobalKey();
final _weightKey = GlobalKey();
List<String> _initialCustomMenu() {
final saved = ref.read(allergenMenuRepositoryProvider).load();
// 이 아이의 알레르기 중 기본·저장 목록에 없는 항목도 메뉴에 보여 준다
final extra = (_editing?.allergies ?? const <String>[])
.where((a) => !kDefaultAllergens.contains(a) && !saved.contains(a));
return [...saved, ...extra];
}
@override
void dispose() {
for (final c in [_name, _weight, _conditions, _watch, _vet, _blood, _memo]) {
c.dispose();
}
super.dispose();
}
bool get _nameMissing => _name.text.trim().isEmpty;
bool get _weightMissing => double.tryParse(_weight.text) == null;
bool get _birthMissing => _birth == null;
bool get _speciesMissing => !_speciesPicked;
bool get _basicFilled => !_nameMissing && !_weightMissing && !_birthMissing && !_speciesMissing;
// ── 선택 시트 ─────────────────────────────────────────
Future<void> _pickSpecies() async {
final choice = await showSpeciesBreedPicker(context, species: _species, breed: _breed);
if (choice == null || !mounted) return;
setState(() {
_species = choice.species;
_breed = choice.breed;
_speciesPicked = true;
});
}
Future<void> _pickBirth() async {
final date = await showBirthDatePicker(context, initial: _birth);
if (date == null || !mounted) return;
setState(() => _birth = date);
}
Future<void> _pickUnit() async {
final unit = await showWeightUnitPicker(context, current: _unit);
if (unit == null || !mounted) return;
setState(() => _unit = unit);
}
Future<void> _pickPhoto() async {
final file = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: _kPhotoMaxSide,
maxHeight: _kPhotoMaxSide,
imageQuality: _kPhotoQuality,
);
if (file == null) return;
final path = await ref.read(petPhotoStoreProvider).persist(file);
if (!mounted) return;
setState(() => _photoPath = path);
}
// ── 알레르기 ───────────────────────────────────────────
void _toggleAllergen(String a) => setState(() {
if (!_allergies.remove(a)) _allergies.add(a);
});
void _addCustomAllergen(String a) {
setState(() {
if (!kDefaultAllergens.contains(a) && !_customMenu.contains(a)) _customMenu.add(a);
_allergies.add(a);
});
unawaited(ref.read(allergenMenuRepositoryProvider).save(_customMenu));
}
void _removeCustomAllergen(String a) {
setState(() {
_customMenu.remove(a);
_allergies.remove(a);
});
unawaited(ref.read(allergenMenuRepositoryProvider).save(_customMenu));
}
// ── 저장 ──────────────────────────────────────────────
PetInput _buildInput() => PetInput(
name: _name.text.trim(),
species: _species,
breed: _breed,
gender: _gender,
neutered: _neutered,
birthDate: _birth!,
weight: double.parse(_weight.text),
weightUnit: _unit,
photoPath: _photoPath,
bloodType: _nullIfBlank(_blood.text),
vet: _nullIfBlank(_vet.text),
memo: _nullIfBlank(_memo.text),
allergies: _allergies.toList(),
conditions: _splitList(_conditions.text),
watchConditions: _splitList(_watch.text),
);
/// 빠진 기본 정보가 있으면 첫 번째 빠진 칸으로 스크롤해 짚어 준다
void _revealFirstMissing() {
final order = [
(_nameMissing, _nameKey),
(_speciesMissing, _speciesKey),
(_birthMissing, _birthKey),
(_weightMissing, _weightKey),
];
final target = order.where((e) => e.$1).firstOrNull?.$2.currentContext;
if (target == null) return;
unawaited(Scrollable.ensureVisible(
target,
alignment: _kScrollAlignment,
duration: AppDurations.fade,
curve: Curves.easeOutCubic,
));
}
Future<void> _submit() async {
if (_saving) return;
if (!_basicFilled) {
setState(() => _showErrors = true);
_revealFirstMissing();
return;
}
setState(() => _saving = true);
final notifier = ref.read(petListProvider.notifier);
final toast = ref.read(toastProvider.notifier);
try {
final editing = _editing;
if (editing == null) {
final pet = await notifier.create(_buildInput());
ref.read(selectedPetIdProvider.notifier).select(pet.id);
toast.show('${pet.name} 등록 완료!');
} else {
await notifier.updatePet(editing.id, _buildInput());
toast.show('반려동물 정보를 저장했어요');
}
if (mounted) context.pop();
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _delete() async {
final editing = _editing;
if (editing == null) return;
final ok = await showRupConfirm(
context,
title: '${withObjectParticle(editing.name)} 삭제할까요?',
message: '아이의 일정과 펫 명함도 함께 삭제되고\n되돌릴 수 없어요.',
confirmLabel: '삭제하기',
destructive: true,
);
if (!ok || !mounted) return;
await ref.read(petListProvider.notifier).deletePet(editing.id);
ref.read(toastProvider.notifier).show('반려동물을 삭제했어요');
if (mounted) context.go(AppRoutes.myPage);
}
// ── 화면 ──────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final editing = _editing;
final err = _showErrors;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
body: SafeArea(
bottom: false,
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xs,
AppSpacing.xl,
MediaQuery.paddingOf(context).bottom + 40,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RupNavHeader(
title: editing == null ? '반려동물 등록' : '반려동물 정보 수정',
subtitle: editing == null ? null : '${editing.name}의 정보를 업데이트해요',
onBack: () => context.pop(),
),
const SizedBox(height: 18),
PetPhotoSlot(photoPath: _photoPath, onPick: _pickPhoto),
const SizedBox(height: AppSpacing.xs),
const PetFormSectionLabel(text: '기본 정보', required: true),
RupField(
key: _nameKey,
label: '이름',
errorText: err && _nameMissing ? '이름을 입력해주세요.' : null,
child: RupTextField(
controller: _name,
hintText: '아이 이름을 입력해주세요',
hasError: err && _nameMissing,
textInputAction: TextInputAction.next,
onChanged: (_) => setState(() {}),
),
),
const SizedBox(height: _kFieldGap),
RupField(
key: _speciesKey,
label: '종',
errorText: err && _speciesMissing ? '종을 선택해주세요.' : null,
child: RupPickerField(
onTap: _pickSpecies,
hasError: err && _speciesMissing,
semanticLabel: '종 선택',
trailing: const RupIcon(RupIconName.chevR, size: 18, color: AppColors.muted),
child: _speciesPicked
? Text.rich(
TextSpan(children: [
TextSpan(
text: _species,
style: const TextStyle(fontWeight: FontWeight.w700),
),
TextSpan(
text: ' · ${_breed.isEmpty ? '세부 품종 선택' : _breed}',
style: TextStyle(
color: _breed.isEmpty ? AppColors.muted : AppColors.ink2,
),
),
]),
)
: const Text('종을 선택해주세요', style: TextStyle(color: AppColors.muted)),
),
),
const SizedBox(height: _kFieldGap),
Row(
children: [
Expanded(
child: RupField(
label: '성별',
child: RupSegmented<PetGender>(
options: [for (final g in PetGender.values) (g, g.label)],
value: _gender,
onChanged: (g) => setState(() => _gender = g),
),
),
),
const SizedBox(width: _kColumnGap),
Expanded(
child: RupField(
label: '중성화',
child: RupSegmented<bool>(
options: const [(true, '완료'), (false, '안 함')],
value: _neutered,
onChanged: (v) => setState(() => _neutered = v),
),
),
),
],
),
const SizedBox(height: 18),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: RupField(
key: _birthKey,
label: '생년월일',
errorText: err && _birthMissing ? '생년월일을 선택해주세요.' : null,
child: RupPickerField(
onTap: _pickBirth,
hasError: err && _birthMissing,
semanticLabel: '생년월일 선택',
child: Text(
_birth == null ? '생년월일 선택' : formatYmdSpaced(_birth!),
style: TextStyle(color: _birth == null ? AppColors.muted : AppColors.ink),
),
),
),
),
const SizedBox(width: _kColumnGap),
Expanded(
child: RupField(
key: _weightKey,
label: '몸무게',
errorText: err && _weightMissing ? '몸무게를 입력해주세요.' : null,
child: WeightInput(
controller: _weight,
unit: _unit,
onUnitTap: _pickUnit,
hasError: err && _weightMissing,
onChanged: (_) => setState(() {}),
),
),
),
],
),
const SizedBox(height: _kFieldGap),
const PetFormSectionLabel(text: '건강 정보', top: 14),
RupField(
label: '현재 보유 질환',
child: RupTextField(controller: _conditions, hintText: '현재 앓고 있는 질환을 입력해주세요'),
),
const SizedBox(height: _kFieldGap),
RupField(
label: '염려되는 질환',
labelSuffix: '(주의가 필요한 항목)',
child: RupTextField(controller: _watch, hintText: '주의가 필요한 질환을 입력해주세요'),
),
const SizedBox(height: _kFieldGap),
Row(
children: [
Expanded(
child: RupField(
label: '담당 병원',
child: RupTextField(controller: _vet, hintText: '병원 이름'),
),
),
const SizedBox(width: 10),
Expanded(
child: RupField(
label: '혈액형',
child: RupTextField(controller: _blood, hintText: '혈액형을 입력해주세요'),
),
),
],
),
const SizedBox(height: _kFieldGap),
RupField(
label: '알레르기',
child: AllergenSelector(
selected: _allergies,
customMenu: _customMenu,
onToggle: _toggleAllergen,
onAddCustom: _addCustomAllergen,
onRemoveCustom: _removeCustomAllergen,
),
),
const PetFormSectionLabel(text: '보호자 메모'),
RupTextField(
controller: _memo,
multiline: true,
hintText: '성격, 진료 시 참고사항 등을 적어주세요.',
),
const SizedBox(height: _kFieldGap + AppSpacing.sm),
RupButton(
label: editing == null ? '반려동물 등록' : '변경사항 저장',
large: true,
thin: true,
dimmed: !_basicFilled,
onPressed: _saving ? null : _submit,
),
if (editing != null)
Padding(
padding: const EdgeInsets.only(top: 22),
child: TextButton(
onPressed: _delete,
child: Text(
'삭제하기',
style: textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.muted,
),
),
),
),
if (!_basicFilled)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'기본 정보를 모두 입력해주세요.',
textAlign: TextAlign.center,
style: textTheme.bodySmall!.copyWith(
fontSize: 12.5,
color: err ? AppColors.error : AppColors.muted,
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,59 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/features/pet/data/repositories/pet_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
/// 등록된 반려동물 목록
class PetListNotifier extends AsyncNotifier<List<Pet>> {
PetRepository get _repo => ref.read(petRepositoryProvider);
@override
Future<List<Pet>> build() => ref.watch(petRepositoryProvider).fetchPets();
Future<Pet> create(PetInput input) async {
final pet = await _repo.create(input);
if (ref.mounted) state = AsyncData([...?state.value, pet]);
return pet;
}
Future<Pet> updatePet(String id, PetInput input) async {
final pet = await _repo.update(id, input);
if (ref.mounted) {
state = AsyncData([for (final p in state.value ?? const <Pet>[]) p.id == id ? pet : p]);
}
return pet;
}
Future<void> deletePet(String id) async {
await _repo.delete(id);
if (ref.mounted) {
state = AsyncData([...?state.value?.where((p) => p.id != id)]);
}
}
}
final petListProvider = AsyncNotifierProvider<PetListNotifier, List<Pet>>(PetListNotifier.new);
/// 홈에서 고른 아이 id
class SelectedPetIdNotifier extends Notifier<String?> {
@override
String? build() => null;
void select(String id) => state = id;
}
final selectedPetIdProvider = NotifierProvider<SelectedPetIdNotifier, String?>(
SelectedPetIdNotifier.new,
);
/// 홈에서 보고 있는 아이 — 고른 아이가 없거나 지워졌으면 첫 번째 아이
final selectedPetProvider = Provider<Pet?>((ref) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final id = ref.watch(selectedPetIdProvider);
return pets.where((p) => p.id == id).firstOrNull ?? pets.firstOrNull;
});
/// 반려동물 id 로 찾기 — 수정 화면 등
final petByIdProvider = Provider.family<Pet?, String>((ref, id) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
return pets.where((p) => p.id == id).firstOrNull;
});
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/features/pet/data/repositories/allergen_menu_repository.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
const int _kCustomMaxLength = 12;
const double _kCustomInputWidth = 76;
/// 알레르기 선택 — 기본 항목 + 직접 추가한 항목(메뉴에서 지울 수 있음) + 직접 추가
class AllergenSelector extends StatefulWidget {
const AllergenSelector({
super.key,
required this.selected,
required this.customMenu,
required this.onToggle,
required this.onAddCustom,
required this.onRemoveCustom,
});
final Set<String> selected;
/// 직접 추가해 둔 항목
final List<String> customMenu;
final ValueChanged<String> onToggle;
final ValueChanged<String> onAddCustom;
final ValueChanged<String> onRemoveCustom;
@override
State<AllergenSelector> createState() => _AllergenSelectorState();
}
class _AllergenSelectorState extends State<AllergenSelector> {
bool _adding = false;
final _input = TextEditingController();
@override
void dispose() {
_input.dispose();
super.dispose();
}
void _commit() {
final value = _input.text.trim();
if (value.isNotEmpty) widget.onAddCustom(value);
_input.clear();
setState(() => _adding = false);
}
@override
Widget build(BuildContext context) {
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
for (final a in kDefaultAllergens)
RupTagChip(
label: a,
selected: widget.selected.contains(a),
onTap: () => widget.onToggle(a),
),
for (final a in widget.customMenu)
RupTagChip(
label: a,
selected: widget.selected.contains(a),
onTap: () => widget.onToggle(a),
onDelete: () => widget.onRemoveCustom(a),
),
if (_adding)
_CustomInputChip(controller: _input, onSubmit: _commit)
else
RupAddChip(label: '직접 추가', onTap: () => setState(() => _adding = true)),
],
);
}
}
class _CustomInputChip extends StatelessWidget {
const _CustomInputChip({required this.controller, required this.onSubmit});
final TextEditingController controller;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.2,
);
return Container(
padding: const EdgeInsets.fromLTRB(11, 3, 5, 3),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: AppColors.primary, width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: _kCustomInputWidth,
child: TextField(
controller: controller,
autofocus: true,
maxLength: _kCustomMaxLength,
style: style,
cursorColor: AppColors.primary,
textInputAction: TextInputAction.done,
onSubmitted: (_) => onSubmit(),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
counterText: '',
hintText: '직접 입력',
hintStyle: style.copyWith(color: AppColors.muted),
contentPadding: EdgeInsets.zero,
),
),
),
const SizedBox(width: 5),
Semantics(
button: true,
label: '추가',
child: GestureDetector(
onTap: onSubmit,
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(AppSpacing.sm),
),
alignment: Alignment.center,
child: const RupIcon(RupIconName.check, size: 15, stroke: 2.6, color: Colors.white),
),
),
),
],
),
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:tma_app/core/constants/app_assets.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
/// 사진이 없을 때 원 테두리 — 시안 .rup-avatar--slot
const Color _kEmptyRing = Color(0xFFC8C8C8);
/// 반려동물 원형 아바타 — 사진이 없으면 기본 강아지 얼굴 아이콘
class PetAvatar extends StatelessWidget {
const PetAvatar({super.key, required this.pet, required this.size});
final Pet pet;
final double size;
@override
Widget build(BuildContext context) {
final photo = pet.photoPath;
return Semantics(
label: '${pet.name} 사진',
image: true,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: photo == null ? Border.all(color: _kEmptyRing, width: 1.5) : null,
),
clipBehavior: Clip.antiAlias,
child: photo == null
? SvgPicture.asset(AppAssets.petDefault, fit: BoxFit.cover)
: PetPhotoImage(path: photo),
),
);
}
}
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
/// 몸무게 단위 버튼 폭 — 시안 72
const double _kUnitButtonWidth = 72;
/// 몸무게 — 숫자와 소수점 하나, 소수 둘째 자리까지
final TextInputFormatter weightInputFormatter = TextInputFormatter.withFunction(
(oldValue, newValue) =>
RegExp(r'^\d{0,4}(\.\d{0,2})?$').hasMatch(newValue.text) ? newValue : oldValue,
);
/// 폼 섹션 제목 — "기본 정보 ●" (필수 섹션은 주황 점)
class PetFormSectionLabel extends StatelessWidget {
const PetFormSectionLabel({super.key, required this.text, this.required = false, this.top = 30});
final String text;
final bool required;
final double top;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: top, bottom: 18),
child: Row(
children: [
Text(
text,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
if (required) ...[
const SizedBox(width: 9),
Semantics(
label: '필수',
child: Container(
width: AppSpacing.sm,
height: AppSpacing.sm,
decoration: const BoxDecoration(color: AppColors.primary, shape: BoxShape.circle),
),
),
],
],
),
);
}
}
/// 몸무게 입력 + 단위 버튼
class WeightInput extends StatelessWidget {
const WeightInput({
super.key,
required this.controller,
required this.unit,
required this.onUnitTap,
required this.hasError,
required this.onChanged,
});
final TextEditingController controller;
final WeightUnit unit;
final VoidCallback onUnitTap;
final bool hasError;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: RupTextField(
controller: controller,
hintText: '무게 입력',
hasError: hasError,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [weightInputFormatter],
onChanged: onChanged,
),
),
const SizedBox(width: AppSpacing.sm),
SizedBox(
width: _kUnitButtonWidth,
child: RupPickerField(
onTap: onUnitTap,
semanticLabel: '몸무게 단위 ${unit.name}',
trailing: const RupIcon(RupIconName.chevD, size: 16, color: AppColors.muted),
child: Text(
unit.name,
style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.ink),
),
),
),
],
);
}
}
@@ -0,0 +1,22 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// 기기에 저장된 반려동물 사진 — 웹 미리보기에서는 브라우저 임시 주소를 쓴다
class PetPhotoImage extends StatelessWidget {
const PetPhotoImage({super.key, required this.path, this.fit = BoxFit.cover});
final String path;
final BoxFit fit;
@override
Widget build(BuildContext context) {
// 사진 파일이 지워졌거나 읽을 수 없으면 빈 칸으로 둔다
Widget error(BuildContext context, Object e, StackTrace? s) => const SizedBox.shrink();
if (kIsWeb) {
return Image.network(path, fit: fit, errorBuilder: error);
}
return Image.file(File(path), fit: fit, errorBuilder: error);
}
}
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:tma_app/core/constants/app_assets.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
/// 등록 화면 사진 칸 높이·모서리 — 시안 image-slot (200, radius 16)
const double _kSlotHeight = 200;
const double _kSlotRadius = 16;
const double _kEditBadge = 34;
/// 반려동물 사진 칸 — 비어 있으면 "우리 아이 사진 추가하기", 있으면 사진 + 바꾸기 표시
class PetPhotoSlot extends StatelessWidget {
const PetPhotoSlot({super.key, required this.photoPath, required this.onPick});
final String? photoPath;
final VoidCallback onPick;
@override
Widget build(BuildContext context) {
final photo = photoPath;
return Semantics(
button: true,
label: photo == null ? '우리 아이 사진 추가하기' : '우리 아이 사진 바꾸기',
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onPick,
child: SizedBox(
height: _kSlotHeight,
width: double.infinity,
child: photo == null ? const _EmptySlot() : _FilledSlot(path: photo),
),
),
),
);
}
}
class _EmptySlot extends StatelessWidget {
const _EmptySlot();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.sand,
borderRadius: BorderRadius.circular(_kSlotRadius),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
AppAssets.addPhoto,
width: 30,
height: 30,
colorFilter: const ColorFilter.mode(AppColors.sandInk, BlendMode.srcIn),
),
const SizedBox(height: 12),
const Text(
'우리 아이 사진 추가하기',
style: TextStyle(
fontFamily: AppFonts.display,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: AppColors.sandInk,
),
),
],
),
),
);
}
}
class _FilledSlot extends StatelessWidget {
const _FilledSlot({required this.path});
final String path;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(_kSlotRadius),
child: PetPhotoImage(path: path),
),
// 사진을 바꿀 수 있다는 표시
Positioned(
right: 10,
bottom: 10,
child: Container(
width: _kEditBadge,
height: _kEditBadge,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .92),
shape: BoxShape.circle,
boxShadow: const [BoxShadow(color: Color(0x26000000), blurRadius: 6)],
),
alignment: Alignment.center,
child: const RupIcon(RupIconName.camera, size: 18, stroke: 2, color: AppColors.ink2),
),
),
],
);
}
}
@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show OverflowBoxFit;
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_species.dart';
import 'package:tma_app/features/pet/presentation/widgets/species_icon.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
import 'package:tma_app/shared/widgets/rup_sheet.dart';
/// 품종 목록 높이 — 시안 320 고정 (검색해도 시트 높이가 흔들리지 않게)
const double _kBreedListHeight = 320;
typedef SpeciesChoice = ({String species, String breed});
/// 종 선택 시트 — 대분류 칩 + 품종 검색 + 품종 목록. 고르면 바로 닫힌다.
Future<SpeciesChoice?> showSpeciesBreedPicker(
BuildContext context, {
required String species,
required String breed,
}) {
return showRupSheet<SpeciesChoice>(
context,
title: '종 선택',
builder: (context) => _SpeciesBreedPicker(species: species, breed: breed),
);
}
class _SpeciesBreedPicker extends StatefulWidget {
const _SpeciesBreedPicker({required this.species, required this.breed});
final String species;
final String breed;
@override
State<_SpeciesBreedPicker> createState() => _SpeciesBreedPickerState();
}
class _SpeciesBreedPickerState extends State<_SpeciesBreedPicker> {
late String _category = widget.species;
final _query = TextEditingController();
@override
void dispose() {
_query.dispose();
super.dispose();
}
// 대분류를 바꾸면 검색어를 비운다 (선택된 대분류 안에서만 검색)
void _chooseCategory(String id) => setState(() {
_category = id;
_query.clear();
});
bool _isCurrent(String choice) =>
widget.species == _category &&
widget.breed == breedValueOf(choice) &&
!(choice == kCustomBreed && widget.breed.isNotEmpty);
@override
Widget build(BuildContext context) {
final q = _query.text.trim().toLowerCase();
final choices = categoryOf(_category)
.choices
.where((b) => q.isEmpty || b.toLowerCase().contains(q))
.toList();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_CategoryChips(selected: _category, onSelect: _chooseCategory),
const SizedBox(height: AppSpacing.md),
RupTextField(
controller: _query,
hintText: '$_category 품종 검색',
textInputAction: TextInputAction.search,
onChanged: (_) => setState(() {}),
// 시안: 돋보기 왼쪽 14, 글자 시작 42
prefix: const Padding(
padding: EdgeInsets.only(right: 10),
child: RupIcon(RupIconName.search, size: 18, color: AppColors.muted),
),
suffix: _query.text.isEmpty
? null
: Semantics(
button: true,
label: '지우기',
child: GestureDetector(
onTap: () => setState(_query.clear),
child: Container(
width: 26,
height: 26,
decoration: const BoxDecoration(
color: AppColors.line2,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const RupIcon(RupIconName.x, size: 14, stroke: 2.4, color: AppColors.muted),
),
),
),
),
const SizedBox(height: AppSpacing.md),
SizedBox(
height: _kBreedListHeight,
child: choices.isEmpty
? Padding(
padding: const EdgeInsets.all(30),
child: Text(
'검색 결과가 없어요',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 14,
color: AppColors.muted,
),
),
)
: _BleedList(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: choices.length,
itemBuilder: (context, i) => _ChoiceRow(
label: choices[i],
selected: _isCurrent(choices[i]),
onTap: () => Navigator.of(context).pop<SpeciesChoice>((
species: _category,
breed: breedValueOf(choices[i]),
)),
),
),
),
),
],
);
}
}
/// 대분류 칩 — 가로로 넘겨 본다. 시트 좌우 여백을 넘어 화면 끝까지 스크롤된다.
class _CategoryChips extends StatelessWidget {
const _CategoryChips({required this.selected, required this.onSelect});
final String selected;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w700,
height: 1.2,
);
return SizedBox(
height: 42,
child: OverflowBox(
maxWidth: MediaQuery.sizeOf(context).width,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl, vertical: 2),
itemCount: kPetCategories.length,
separatorBuilder: (context, index) => const SizedBox(width: AppSpacing.sm),
itemBuilder: (context, i) {
final c = kPetCategories[i];
final on = c.id == selected;
final fg = on ? Colors.white : AppColors.ink2;
return Semantics(
button: true,
selected: on,
child: GestureDetector(
onTap: () => onSelect(c.id),
child: AnimatedContainer(
duration: AppDurations.fast,
padding: const EdgeInsets.fromLTRB(11, 8, 15, 8),
decoration: BoxDecoration(
color: on ? AppColors.primary : AppColors.surface2,
borderRadius: BorderRadius.circular(AppRadius.pill),
border: Border.all(
color: on ? AppColors.primary : AppColors.line,
width: 1.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SpeciesIcon(species: c.id, color: fg),
const SizedBox(width: 7),
Text(c.id, style: style.copyWith(color: fg)),
],
),
),
),
);
},
),
),
);
}
}
/// 선택지 목록은 시트 여백보다 좌우 6 씩 넓게 둔다 (시안 margin: 0 -6px)
class _BleedList extends StatelessWidget {
const _BleedList({required this.child});
static const double _bleed = 6;
final Widget child;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) => OverflowBox(
minWidth: constraints.maxWidth + _bleed * 2,
maxWidth: constraints.maxWidth + _bleed * 2,
fit: OverflowBoxFit.deferToChild,
child: child,
),
);
}
}
/// 선택지 한 줄 — 고른 항목은 연주황 바탕 + 체크
class _ChoiceRow extends StatelessWidget {
const _ChoiceRow({required this.label, required this.selected, required this.onTap});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
decoration: BoxDecoration(
color: selected ? AppColors.primary50 : Colors.transparent,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? AppColors.primary : Colors.transparent,
width: 1.5,
),
),
child: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
),
),
),
if (selected)
const RupIcon(RupIconName.check, size: 18, stroke: 2.6, color: AppColors.primary),
],
),
),
),
),
);
}
}
/// 몸무게 단위 선택 시트
Future<WeightUnit?> showWeightUnitPicker(BuildContext context, {required WeightUnit current}) {
return showRupSheet<WeightUnit>(
context,
title: '단위 선택',
builder: (context) => _BleedList(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final unit in WeightUnit.values)
_ChoiceRow(
label: unit.label,
selected: unit == current,
onTap: () => Navigator.of(context).pop(unit),
),
],
),
),
);
}
@@ -0,0 +1,24 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:tma_app/core/constants/app_assets.dart';
import 'package:tma_app/features/pet/domain/pet_species.dart';
/// 종 실루엣 아이콘 — 한 가지 색으로 칠한다 (시안 SpeciesIcon 의 마스크 방식)
class SpeciesIcon extends StatelessWidget {
const SpeciesIcon({super.key, required this.species, required this.color, this.size = 20});
final String species;
final Color color;
final double size;
@override
Widget build(BuildContext context) {
return SvgPicture.asset(
AppAssets.speciesIcon(categoryOf(species).iconKey),
width: size,
height: size,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
excludeFromSemantics: true,
);
}
}