diff --git a/flutter/assets/svg/common/add_card.svg b/flutter/assets/svg/common/add_card.svg new file mode 100644 index 0000000..23180d0 --- /dev/null +++ b/flutter/assets/svg/common/add_card.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/common/add_photo.svg b/flutter/assets/svg/common/add_photo.svg new file mode 100644 index 0000000..d0657f1 --- /dev/null +++ b/flutter/assets/svg/common/add_photo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flutter/assets/svg/common/person_default.svg b/flutter/assets/svg/common/person_default.svg new file mode 100644 index 0000000..0837ef8 --- /dev/null +++ b/flutter/assets/svg/common/person_default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flutter/assets/svg/common/pet_default.svg b/flutter/assets/svg/common/pet_default.svg new file mode 100644 index 0000000..8d278ad --- /dev/null +++ b/flutter/assets/svg/common/pet_default.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/bird.svg b/flutter/assets/svg/species/bird.svg new file mode 100644 index 0000000..0a24b1e --- /dev/null +++ b/flutter/assets/svg/species/bird.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/cat.svg b/flutter/assets/svg/species/cat.svg new file mode 100644 index 0000000..789f399 --- /dev/null +++ b/flutter/assets/svg/species/cat.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/dog.svg b/flutter/assets/svg/species/dog.svg new file mode 100644 index 0000000..ac8d742 --- /dev/null +++ b/flutter/assets/svg/species/dog.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/ferret.svg b/flutter/assets/svg/species/ferret.svg new file mode 100644 index 0000000..450f731 --- /dev/null +++ b/flutter/assets/svg/species/ferret.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/fish.svg b/flutter/assets/svg/species/fish.svg new file mode 100644 index 0000000..32cca27 --- /dev/null +++ b/flutter/assets/svg/species/fish.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/guineapig.svg b/flutter/assets/svg/species/guineapig.svg new file mode 100644 index 0000000..6bf9c04 --- /dev/null +++ b/flutter/assets/svg/species/guineapig.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/hamster.svg b/flutter/assets/svg/species/hamster.svg new file mode 100644 index 0000000..5814a33 --- /dev/null +++ b/flutter/assets/svg/species/hamster.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/hedgehog.svg b/flutter/assets/svg/species/hedgehog.svg new file mode 100644 index 0000000..39f7dbb --- /dev/null +++ b/flutter/assets/svg/species/hedgehog.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/lizard.svg b/flutter/assets/svg/species/lizard.svg new file mode 100644 index 0000000..c6692b4 --- /dev/null +++ b/flutter/assets/svg/species/lizard.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/other.svg b/flutter/assets/svg/species/other.svg new file mode 100644 index 0000000..36fff5d --- /dev/null +++ b/flutter/assets/svg/species/other.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/rabbit.svg b/flutter/assets/svg/species/rabbit.svg new file mode 100644 index 0000000..cc602b8 --- /dev/null +++ b/flutter/assets/svg/species/rabbit.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/snake.svg b/flutter/assets/svg/species/snake.svg new file mode 100644 index 0000000..81826d9 --- /dev/null +++ b/flutter/assets/svg/species/snake.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/species/turtle.svg b/flutter/assets/svg/species/turtle.svg new file mode 100644 index 0000000..312f6be --- /dev/null +++ b/flutter/assets/svg/species/turtle.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/flutter/ios/Runner/Info.plist b/flutter/ios/Runner/Info.plist index a58c4ae..333ac90 100644 --- a/flutter/ios/Runner/Info.plist +++ b/flutter/ios/Runner/Info.plist @@ -8,6 +8,8 @@ $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Tma App + NSPhotoLibraryUsageDescription + 우리 아이 프로필 사진을 등록하기 위해 사진 보관함에 접근합니다. CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/flutter/lib/core/constants/app_assets.dart b/flutter/lib/core/constants/app_assets.dart index e6ac336..2e2e1e3 100644 --- a/flutter/lib/core/constants/app_assets.dart +++ b/flutter/lib/core/constants/app_assets.dart @@ -33,4 +33,14 @@ abstract final class AppAssets { static const String onb3Sparkle3 = 'assets/images/onboarding/onb3_sp3.png'; static const String onb4Base = 'assets/images/onboarding/onb4_base.png'; static const String onb4Heart = 'assets/images/onboarding/onb4_ch.png'; + + // 공통 + static const String petDefault = 'assets/svg/common/pet_default.svg'; + static const String personDefault = 'assets/svg/common/person_default.svg'; + static const String addPhoto = 'assets/svg/common/add_photo.svg'; + static const String addCard = 'assets/svg/common/add_card.svg'; + + /// 종 실루엣 아이콘 — [speciesIconKey] 는 dog·cat·… (없으면 other) + static String speciesIcon(String speciesIconKey) => + 'assets/svg/species/$speciesIconKey.svg'; } diff --git a/flutter/lib/features/pet/data/pet_photo_store.dart b/flutter/lib/features/pet/data/pet_photo_store.dart new file mode 100644 index 0000000..b2be49c --- /dev/null +++ b/flutter/lib/features/pet/data/pet_photo_store.dart @@ -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 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((ref) => const PetPhotoStore()); diff --git a/flutter/lib/features/pet/data/repositories/allergen_menu_repository.dart b/flutter/lib/features/pet/data/repositories/allergen_menu_repository.dart new file mode 100644 index 0000000..189e3ed --- /dev/null +++ b/flutter/lib/features/pet/data/repositories/allergen_menu_repository.dart @@ -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 kDefaultAllergens = [ + '닭고기', '소고기', '돼지고기', '유제품', '밀', '옥수수', '계란', '생선', '콩', +]; + +const String _kCustomAllergensKey = 'pet.customAllergens'; + +/// 사용자가 "직접 추가" 한 알레르기 항목 — 다음 등록 때도 메뉴에 남는다. +class AllergenMenuRepository { + AllergenMenuRepository(this._prefs); + + final SharedPreferences _prefs; + + List load() => _prefs.getStringList(_kCustomAllergensKey) ?? const []; + + Future save(List items) => _prefs.setStringList( + _kCustomAllergensKey, + items.where((a) => !kDefaultAllergens.contains(a)).toList(), + ); +} + +final allergenMenuRepositoryProvider = Provider((ref) { + return AllergenMenuRepository(ref.watch(sharedPreferencesProvider)); +}); diff --git a/flutter/lib/features/pet/data/repositories/pet_repository.dart b/flutter/lib/features/pet/data/repositories/pet_repository.dart new file mode 100644 index 0000000..3d14b0b --- /dev/null +++ b/flutter/lib/features/pet/data/repositories/pet_repository.dart @@ -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> fetchPets(); + + Future create(PetInput input); + + Future update(String id, PetInput input); + + Future 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 _read() { + final raw = _prefs.getString(_kPetsKey); + if (raw == null) return []; + final list = jsonDecode(raw) as List; + return list.whereType>().map(Pet.fromJson).toList(); + } + + Future _write(List pets) => + _prefs.setString(_kPetsKey, jsonEncode([for (final p in pets) p.toJson()])); + + @override + Future> fetchPets() async => _read(); + + @override + Future create(PetInput input) async { + final pet = Pet.fromInput('pet-${_clock().microsecondsSinceEpoch}', input); + await _write([..._read(), pet]); + return pet; + } + + @override + Future 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 delete(String id) async { + await _write(_read().where((p) => p.id != id).toList()); + } +} + +final petRepositoryProvider = Provider((ref) { + return LocalPetRepository(ref.watch(sharedPreferencesProvider)); +}); diff --git a/flutter/lib/features/pet/domain/pet.dart b/flutter/lib/features/pet/domain/pet.dart new file mode 100644 index 0000000..fd8e732 --- /dev/null +++ b/flutter/lib/features/pet/domain/pet.dart @@ -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 allergies; + + /// 현재 보유 질환 + final List conditions; + + /// 염려되는 질환 (주의가 필요한 항목) + final List 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 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 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 _stringList(Object? value) => + value is List ? value.whereType().toList() : const []; diff --git a/flutter/lib/features/pet/domain/pet_age.dart b/flutter/lib/features/pet/domain/pet_age.dart new file mode 100644 index 0000000..a776136 --- /dev/null +++ b/flutter/lib/features/pet/domain/pet_age.dart @@ -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개월'; +} diff --git a/flutter/lib/features/pet/domain/pet_species.dart b/flutter/lib/features/pet/domain/pet_species.dart new file mode 100644 index 0000000..005b487 --- /dev/null +++ b/flutter/lib/features/pet/domain/pet_species.dart @@ -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 breeds; + + /// 선택 목록 — 종을 모르는 경우를 맨 위에 둔다 + List 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 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); diff --git a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart new file mode 100644 index 0000000..67fc030 --- /dev/null +++ b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart @@ -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 _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 createState() => _PetRegisterPageState(); +} + +class _PetRegisterPageState extends ConsumerState { + 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 _allergies = {...?_editing?.allergies}; + late final List _customMenu = _initialCustomMenu(); + + bool _showErrors = false; + bool _saving = false; + + // 빠진 칸으로 스크롤하기 위한 위치 표식 + final _nameKey = GlobalKey(); + final _speciesKey = GlobalKey(); + final _birthKey = GlobalKey(); + final _weightKey = GlobalKey(); + + List _initialCustomMenu() { + final saved = ref.read(allergenMenuRepositoryProvider).load(); + // 이 아이의 알레르기 중 기본·저장 목록에 없는 항목도 메뉴에 보여 준다 + final extra = (_editing?.allergies ?? const []) + .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 _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 _pickBirth() async { + final date = await showBirthDatePicker(context, initial: _birth); + if (date == null || !mounted) return; + setState(() => _birth = date); + } + + Future _pickUnit() async { + final unit = await showWeightUnitPicker(context, current: _unit); + if (unit == null || !mounted) return; + setState(() => _unit = unit); + } + + Future _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 _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 _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( + 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( + 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, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/pet/presentation/providers/pet_providers.dart b/flutter/lib/features/pet/presentation/providers/pet_providers.dart new file mode 100644 index 0000000..fcd97d2 --- /dev/null +++ b/flutter/lib/features/pet/presentation/providers/pet_providers.dart @@ -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> { + PetRepository get _repo => ref.read(petRepositoryProvider); + + @override + Future> build() => ref.watch(petRepositoryProvider).fetchPets(); + + Future create(PetInput input) async { + final pet = await _repo.create(input); + if (ref.mounted) state = AsyncData([...?state.value, pet]); + return pet; + } + + Future 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 []) p.id == id ? pet : p]); + } + return pet; + } + + Future deletePet(String id) async { + await _repo.delete(id); + if (ref.mounted) { + state = AsyncData([...?state.value?.where((p) => p.id != id)]); + } + } +} + +final petListProvider = AsyncNotifierProvider>(PetListNotifier.new); + +/// 홈에서 고른 아이 id +class SelectedPetIdNotifier extends Notifier { + @override + String? build() => null; + + void select(String id) => state = id; +} + +final selectedPetIdProvider = NotifierProvider( + SelectedPetIdNotifier.new, +); + +/// 홈에서 보고 있는 아이 — 고른 아이가 없거나 지워졌으면 첫 번째 아이 +final selectedPetProvider = Provider((ref) { + final pets = ref.watch(petListProvider).value ?? const []; + final id = ref.watch(selectedPetIdProvider); + return pets.where((p) => p.id == id).firstOrNull ?? pets.firstOrNull; +}); + +/// 반려동물 id 로 찾기 — 수정 화면 등 +final petByIdProvider = Provider.family((ref, id) { + final pets = ref.watch(petListProvider).value ?? const []; + return pets.where((p) => p.id == id).firstOrNull; +}); diff --git a/flutter/lib/features/pet/presentation/widgets/allergen_selector.dart b/flutter/lib/features/pet/presentation/widgets/allergen_selector.dart new file mode 100644 index 0000000..a14d0b7 --- /dev/null +++ b/flutter/lib/features/pet/presentation/widgets/allergen_selector.dart @@ -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 selected; + + /// 직접 추가해 둔 항목 + final List customMenu; + final ValueChanged onToggle; + final ValueChanged onAddCustom; + final ValueChanged onRemoveCustom; + + @override + State createState() => _AllergenSelectorState(); +} + +class _AllergenSelectorState extends State { + 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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/pet/presentation/widgets/pet_avatar.dart b/flutter/lib/features/pet/presentation/widgets/pet_avatar.dart new file mode 100644 index 0000000..7e29d4d --- /dev/null +++ b/flutter/lib/features/pet/presentation/widgets/pet_avatar.dart @@ -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), + ), + ); + } +} diff --git a/flutter/lib/features/pet/presentation/widgets/pet_form_parts.dart b/flutter/lib/features/pet/presentation/widgets/pet_form_parts.dart new file mode 100644 index 0000000..6027295 --- /dev/null +++ b/flutter/lib/features/pet/presentation/widgets/pet_form_parts.dart @@ -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 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), + ), + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/pet/presentation/widgets/pet_photo_image.dart b/flutter/lib/features/pet/presentation/widgets/pet_photo_image.dart new file mode 100644 index 0000000..6dc54b0 --- /dev/null +++ b/flutter/lib/features/pet/presentation/widgets/pet_photo_image.dart @@ -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); + } +} diff --git a/flutter/lib/features/pet/presentation/widgets/pet_photo_slot.dart b/flutter/lib/features/pet/presentation/widgets/pet_photo_slot.dart new file mode 100644 index 0000000..0eeb428 --- /dev/null +++ b/flutter/lib/features/pet/presentation/widgets/pet_photo_slot.dart @@ -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), + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/pet/presentation/widgets/pet_pick_sheets.dart b/flutter/lib/features/pet/presentation/widgets/pet_pick_sheets.dart new file mode 100644 index 0000000..11c0b32 --- /dev/null +++ b/flutter/lib/features/pet/presentation/widgets/pet_pick_sheets.dart @@ -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 showSpeciesBreedPicker( + BuildContext context, { + required String species, + required String breed, +}) { + return showRupSheet( + 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(( + species: _category, + breed: breedValueOf(choices[i]), + )), + ), + ), + ), + ), + ], + ); + } +} + +/// 대분류 칩 — 가로로 넘겨 본다. 시트 좌우 여백을 넘어 화면 끝까지 스크롤된다. +class _CategoryChips extends StatelessWidget { + const _CategoryChips({required this.selected, required this.onSelect}); + + final String selected; + final ValueChanged 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 showWeightUnitPicker(BuildContext context, {required WeightUnit current}) { + return showRupSheet( + 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), + ), + ], + ), + ), + ); +} diff --git a/flutter/lib/features/pet/presentation/widgets/species_icon.dart b/flutter/lib/features/pet/presentation/widgets/species_icon.dart new file mode 100644 index 0000000..bd67e9c --- /dev/null +++ b/flutter/lib/features/pet/presentation/widgets/species_icon.dart @@ -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, + ); + } +} diff --git a/flutter/linux/flutter/generated_plugin_registrant.cc b/flutter/linux/flutter/generated_plugin_registrant.cc index d0e7f79..85a2413 100644 --- a/flutter/linux/flutter/generated_plugin_registrant.cc +++ b/flutter/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/flutter/linux/flutter/generated_plugins.cmake b/flutter/linux/flutter/generated_plugins.cmake index ce58916..7aea3ec 100644 --- a/flutter/linux/flutter/generated_plugins.cmake +++ b/flutter/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux ) diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index ee71999..5d64815 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,12 @@ import FlutterMacOS import Foundation +import file_selector_macos import flutter_secure_storage_darwin import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index ef237e3..8fc6540 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -153,6 +161,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" flutter: dependency: "direct main" description: flutter @@ -174,6 +214,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: @@ -304,6 +352,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + url: "https://pub.dev" + source: hosted + version: "0.8.13+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "852a7762962f3bb3ad54ef1d342669e41557f466033a676baa8876b367cf4050" + url: "https://pub.dev" + source: hosted + version: "0.8.13+8" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" io: dependency: transitive description: @@ -449,13 +561,13 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -847,4 +959,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.1 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.41.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 7af0604..cf0e5fb 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -41,6 +41,8 @@ dependencies: go_router: ^17.5.0 flutter_svg: ^2.3.0 shared_preferences: ^2.5.5 + image_picker: ^1.2.3 + path_provider: ^2.1.6 dev_dependencies: flutter_test: @@ -73,6 +75,8 @@ flutter: - assets/images/login/ - assets/svg/intro/ - assets/svg/login/ + - assets/svg/common/ + - assets/svg/species/ # 폰트 — 출처·라이선스는 assets/fonts/README.md fonts: diff --git a/flutter/test/pet_domain_test.dart b/flutter/test/pet_domain_test.dart new file mode 100644 index 0000000..9c88b5b --- /dev/null +++ b/flutter/test/pet_domain_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.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/repositories/pet_repository.dart'; +import 'package:tma_app/features/pet/domain/pet.dart'; +import 'package:tma_app/features/pet/domain/pet_age.dart'; +import 'package:tma_app/features/pet/domain/pet_species.dart'; + +PetInput _input({String name = '콩이', double weight = 3.2}) => PetInput( + name: name, + species: '강아지', + breed: '말티즈', + gender: PetGender.male, + neutered: true, + birthDate: DateTime(2021, 9, 14), + weight: weight, + weightUnit: WeightUnit.kg, + allergies: const ['닭고기'], + conditions: const ['슬개골 탈구'], + ); + +void main() { + group('petAgeLabel', () { + test('1살이 안 되면 개월 수만 보여 준다', () { + expect(petAgeLabel(DateTime(2026, 3, 10), DateTime(2026, 9, 28)), '6개월'); + }); + + test('1살 이상이면 "N살 M개월" 로 보여 준다', () { + expect(petAgeLabel(DateTime(2021, 9, 14), DateTime(2026, 9, 28)), '5살 0개월'); + }); + + test('이번 달 생일 날짜가 아직 안 왔으면 한 달을 덜 센다', () { + expect(petAgeLabel(DateTime(2025, 9, 30), DateTime(2026, 9, 28)), '11개월'); + }); + + test('미래 날짜는 0개월로 처리한다', () { + expect(petAgeLabel(DateTime(2027, 1, 1), DateTime(2026, 9, 28)), '0개월'); + }); + }); + + group('한국어 조사', () { + test('받침이 있으면 을, 없으면 를', () { + expect(withObjectParticle('콩이'), '콩이를'); + expect(withObjectParticle('사랑'), '사랑을'); + }); + + test('한글이 아닌 이름은 를 로 붙인다', () { + expect(withObjectParticle('Max'), 'Max를'); + }); + + test('주격 조사 — 받침이 있으면 이, 없으면 가', () { + expect(withSubjectParticle('초코'), '초코가'); + expect(withSubjectParticle('별'), '별이'); + }); + }); + + group('날짜 표기', () { + test('시안 형식으로 표기한다', () { + final d = DateTime(2021, 9, 4); + expect(formatYmd(d), '2021.09.04'); + expect(formatYmdSpaced(d), '2021 . 09 . 04'); + expect(formatMonthDay(d), '9월 4일'); + expect(weekdayKo(DateTime(2026, 9, 28)), '월'); + }); + + test('월별 일 수는 윤년을 반영한다', () { + expect(daysInMonth(2024, 2), 29); + expect(daysInMonth(2025, 2), 28); + expect(daysInMonth(2025, 12), 31); + }); + + test('ISO 날짜 문자열과 서로 바꿀 수 있다', () { + final d = DateTime(2021, 9, 14); + expect(parseIsoDate(toIsoDate(d)), d); + }); + }); + + group('품종 선택값', () { + test('종 모름 항목은 괄호 설명을 뗀 값으로 저장한다', () { + expect(breedValueOf(kUnknownBreed), kUnknownBreedValue); + }); + + test('직접 입력은 세부 품종을 비워 둔다', () { + expect(breedValueOf(kCustomBreed), ''); + }); + + test('모든 대분류의 선택 목록 맨 위에는 종 모름 항목이 있다', () { + for (final c in kPetCategories) { + expect(c.choices.first, kUnknownBreed); + } + }); + }); + + group('Pet', () { + test('몸무게 표기는 소수점 끝의 0 을 떼어 낸다', () { + expect(Pet.fromInput('a', _input()).weightLabel, '3.2kg'); + expect(Pet.fromInput('a', _input(weight: 4)).weightLabel, '4kg'); + }); + + test('JSON 으로 저장했다가 그대로 되살릴 수 있다', () { + final pet = Pet.fromInput('pet-1', _input()); + final restored = Pet.fromJson(pet.toJson()); + expect(restored.toJson(), pet.toJson()); + }); + }); + + group('LocalPetRepository', () { + late LocalPetRepository repo; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + var tick = 0; + repo = LocalPetRepository( + await SharedPreferences.getInstance(), + clock: () => DateTime.fromMicrosecondsSinceEpoch(++tick), + ); + }); + + test('등록한 순서대로 목록에 쌓인다', () async { + await repo.create(_input(name: '콩이')); + await repo.create(_input(name: '보리')); + final pets = await repo.fetchPets(); + expect(pets.map((p) => p.name), ['콩이', '보리']); + expect(pets.map((p) => p.id).toSet().length, 2); + }); + + test('수정하면 같은 자리의 값이 바뀐다', () async { + final a = await repo.create(_input(name: '콩이')); + await repo.create(_input(name: '보리')); + await repo.update(a.id, _input(name: '콩순이')); + expect((await repo.fetchPets()).map((p) => p.name), ['콩순이', '보리']); + }); + + test('삭제하면 목록에서 빠진다', () async { + final a = await repo.create(_input(name: '콩이')); + await repo.delete(a.id); + expect(await repo.fetchPets(), isEmpty); + }); + }); +} diff --git a/flutter/windows/flutter/generated_plugin_registrant.cc b/flutter/windows/flutter/generated_plugin_registrant.cc index 0c50753..b53f20e 100644 --- a/flutter/windows/flutter/generated_plugin_registrant.cc +++ b/flutter/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/flutter/windows/flutter/generated_plugins.cmake b/flutter/windows/flutter/generated_plugins.cmake index d0b33f8..4873bb8 100644 --- a/flutter/windows/flutter/generated_plugins.cmake +++ b/flutter/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows flutter_secure_storage_windows )