generated from ttipo/project-base
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:
@@ -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));
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user