diff --git a/app/lib/models/pet_model.dart b/app/lib/models/pet_model.dart index bac9c07..20fba22 100644 --- a/app/lib/models/pet_model.dart +++ b/app/lib/models/pet_model.dart @@ -59,25 +59,60 @@ class Pet { } factory Pet.fromMap(Map map) { + DateTime? parseDate(dynamic value) { + if (value is Timestamp) return value.toDate(); + if (value is String) return DateTime.tryParse(value); + return null; + } + + // Helper to safely parse List + List parseList(dynamic list) { + if (list == null) return []; + if (list is! List) return []; + + return list.map((e) { + if (e is String) return e; + if (e is Map) return e['name']?.toString() ?? e.toString(); + return e.toString(); + }).toList(); + } + return Pet( - id: map['id'] ?? '', - ownerId: map['ownerId'] ?? '', + id: map['id']?.toString() ?? '', + ownerId: + map['userId']?.toString() ?? + map['ownerId']?.toString() ?? + '', // Backend sends userId name: map['name'] ?? '', species: map['species'] ?? '', breed: map['breed'] ?? '', gender: map['gender'] ?? '', - isNeutered: map['isNeutered'] ?? false, - birthDate: map['birthDate'] != null - ? (map['birthDate'] as Timestamp).toDate() - : null, - isDateUnknown: map['isDateUnknown'] ?? false, + isNeutered: + map['isNeutered'] == true || + map['isNeutered'] == 'true', // Handle string/bool + birthDate: parseDate(map['birthDate']), + isDateUnknown: + map['isDateUnknown'] == true || map['isDateUnknown'] == 'true', registrationNumber: map['registrationNumber'], - profileImageUrl: map['profileImageUrl'], - weight: map['weight']?.toDouble(), - diseases: List.from(map['diseases'] ?? []), - pastDiseases: List.from(map['pastDiseases'] ?? []), - healthConcerns: List.from(map['healthConcerns'] ?? []), - createdAt: (map['createdAt'] as Timestamp).toDate(), + profileImageUrl: _parseImageUrl( + map['profileImageUrl'] ?? map['profileImagePath'], + ), + weight: map['weight'] is int + ? (map['weight'] as int).toDouble() + : map['weight']?.toDouble(), + diseases: parseList(map['diseases']), + pastDiseases: parseList(map['pastDiseases']), + healthConcerns: parseList(map['healthConcerns']), + createdAt: parseDate(map['createdAt']) ?? DateTime.now(), ); } + + static String? _parseImageUrl(String? url) { + if (url == null || url.isEmpty) return null; + if (url.startsWith('http')) return url; + // Prepend Backend URL for relative paths (MySQL/Multer) + // TODO: Improve architecture to not hardcode interactions in Model + const baseUrl = 'http://10.0.2.2:3000'; + return '$baseUrl$url'; + } } diff --git a/app/lib/screens/daily_care_screen.dart b/app/lib/screens/daily_care_screen.dart index dfbbf0b..f2f70d8 100644 --- a/app/lib/screens/daily_care_screen.dart +++ b/app/lib/screens/daily_care_screen.dart @@ -482,7 +482,7 @@ class _DailyCareScreenState extends State { // 바깥 컨테이너: 행 구분선 담당 decoration: const BoxDecoration( border: Border( - bottom: BorderSide(color: Color(0xFFE0E0E0), width: 1), + top: BorderSide(color: Color(0xFFE0E0E0), width: 1), ), ), child: Container( diff --git a/app/lib/screens/home_screen.dart b/app/lib/screens/home_screen.dart index f82291a..0ced3fc 100644 --- a/app/lib/screens/home_screen.dart +++ b/app/lib/screens/home_screen.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'pet_form_screen.dart'; -import '../services/firestore_service.dart'; +import '../services/api_service.dart'; +import '../services/auth_service.dart'; import '../models/pet_model.dart'; import '../theme/app_colors.dart'; import '../widgets/home/pet_profile_card.dart'; @@ -15,14 +16,47 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - final FirestoreService _firestoreService = FirestoreService(); - String? _userId; + final ApiService _apiService = ApiService(); + final AuthService _authService = AuthService(); + int? _userId; Pet? _selectedPet; + Future>? _petsFuture; @override void initState() { super.initState(); - _userId = _firestoreService.getCurrentUserId(); + _loadUserAndPets(); + } + + Future _loadUserAndPets() async { + final userInfo = await _authService.getUserInfo(); + if (userInfo != null) { + setState(() { + _userId = userInfo['id'] is int + ? userInfo['id'] + : int.tryParse(userInfo['id'].toString()); + _petsFuture = _fetchPets(); + }); + } + } + + Future> _fetchPets() async { + if (_userId == null) return []; + try { + final petsData = await _apiService.getPets(_userId!); + return petsData.map((e) => Pet.fromMap(e)).toList(); + } catch (e) { + debugPrint('Error loading pets: $e'); + return []; + } + } + + void _refreshPets() { + if (_userId != null) { + setState(() { + _petsFuture = _fetchPets(); + }); + } } @override @@ -34,8 +68,8 @@ class _HomeScreenState extends State { return Scaffold( backgroundColor: Colors.white, body: SafeArea( - child: StreamBuilder>( - stream: _firestoreService.getPets(_userId!), + child: FutureBuilder>( + future: _petsFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); @@ -67,10 +101,16 @@ class _HomeScreenState extends State { MaterialPageRoute( builder: (context) => const PetFormScreen(), ), - ); + ).then((value) { + if (value == true) _refreshPets(); + }); }, child: Padding( - padding: EdgeInsets.symmetric(vertical: 4.h), + padding: EdgeInsets.only( + top: 4.h, + bottom: 4.h, + right: 12.w, + ), // Added right padding child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -79,7 +119,7 @@ class _HomeScreenState extends State { width: 40.w, height: 40.h, ), - SizedBox(width: 10.w), + SizedBox(width: 6.w), // Reduced spacing Text( '반려동물 등록 +', style: TextStyle( @@ -153,7 +193,9 @@ class _HomeScreenState extends State { MaterialPageRoute( builder: (context) => const PetFormScreen(), ), - ); + ).then((value) { + if (value == true) _refreshPets(); + }); } }, itemBuilder: (context) { @@ -282,7 +324,14 @@ class _HomeScreenState extends State { ), Expanded( child: SingleChildScrollView( - child: Column(children: [PetProfileCard(pet: displayPet)]), + child: Column( + children: [ + PetProfileCard( + pet: displayPet, + onPetUpdated: _refreshPets, + ), + ], + ), ), ), ], diff --git a/app/lib/screens/my_info_screen.dart b/app/lib/screens/my_info_screen.dart index c995e3f..dd53ce9 100644 --- a/app/lib/screens/my_info_screen.dart +++ b/app/lib/screens/my_info_screen.dart @@ -162,7 +162,7 @@ class _MyInfoScreenState extends State { style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, - color: Color(0xFFFF7500), // 강조색 + color: Color(0xFFFF7500), fontFamily: 'SCDream', ), ), @@ -343,9 +343,9 @@ class _MyInfoScreenState extends State { Widget _buildMenuItem({ required String title, required IconData icon, - VoidCallback? onTap, // onTap을 nullable로 변경 + VoidCallback? onTap, bool isDestructive = false, - String? trailingText, // 뒤에 텍스트를 표시할 수 있도록 추가 + String? trailingText, }) { return Container( decoration: BoxDecoration( @@ -370,7 +370,7 @@ class _MyInfoScreenState extends State { style: TextStyle( fontFamily: 'SCDream', fontSize: 16, - fontWeight: FontWeight.w500, // Medium + fontWeight: FontWeight.w500, color: isDestructive ? Colors.red : Colors.black, ), ), diff --git a/app/lib/screens/notice_screen.dart b/app/lib/screens/notice_screen.dart index 76e2821..67a9226 100644 --- a/app/lib/screens/notice_screen.dart +++ b/app/lib/screens/notice_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import screenutil +import 'package:flutter_screenutil/flutter_screenutil.dart'; class NoticeScreen extends StatelessWidget { const NoticeScreen({super.key}); diff --git a/app/lib/screens/pet_detail_screen.dart b/app/lib/screens/pet_detail_screen.dart index 6bc880c..479bb4a 100644 --- a/app/lib/screens/pet_detail_screen.dart +++ b/app/lib/screens/pet_detail_screen.dart @@ -1,15 +1,15 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; + import 'package:image_picker/image_picker.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../theme/app_colors.dart'; import '../data/pet_data.dart'; -import '../widgets/pet_registration/selection_modal.dart'; -import '../widgets/pet_registration/input_formatters.dart'; + import '../services/firestore_service.dart'; import '../models/pet_model.dart'; +import '../widgets/pet_registration/input_formatters.dart'; class PetDetailScreen extends StatefulWidget { final Pet pet; @@ -124,6 +124,9 @@ class _PetDetailScreenState extends State { String otherText = ''; for (var item in source) { + if (item.trim().isEmpty || item == '[]' || item.contains('[')) + continue; // Filter out bad data + if (item.startsWith('기타(') && item.endsWith(')')) { selected.add('기타'); otherText = item.substring(3, item.length - 1); @@ -153,10 +156,6 @@ class _PetDetailScreenState extends State { final TextEditingController _genderController = TextEditingController(); final TextEditingController _weightController = TextEditingController(); - // 선택된 종 정보 (품종 선택을 위해 필요) - String? _currentMajorCategory; - String? _currentMinorCategory; - String? _selectedGender; bool _isNeutered = false; List _selectedDiseases = []; @@ -482,7 +481,12 @@ class _PetDetailScreenState extends State { decoration: const InputDecoration( hintText: '직접 입력해 주세요', isDense: true, - border: OutlineInputBorder(), + border: const OutlineInputBorder(), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide( + color: AppColors.highlight, + ), + ), ), ), ), @@ -546,7 +550,7 @@ class _PetDetailScreenState extends State { height: 52.h, decoration: BoxDecoration( color: AppColors.highlight, - borderRadius: BorderRadius.circular(12.r), + borderRadius: BorderRadius.circular(30.r), ), child: Center( child: Text( @@ -656,8 +660,7 @@ class _PetDetailScreenState extends State { setState(() { _speciesController.text = speciesInputController.text; - _currentMajorCategory = null; - _currentMinorCategory = null; + _breedController.clear(); }); Navigator.pop(context); @@ -696,8 +699,6 @@ class _PetDetailScreenState extends State { setModalState(() => showInput = true); else { setState(() { - _currentMajorCategory = selectedMajor; - _currentMinorCategory = minor; _speciesController.text = minor; _breedController.clear(); }); @@ -940,6 +941,15 @@ class _PetDetailScreenState extends State { controller: _yearController, hint: 'YYYY', keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + ], + onChanged: (value) { + if (value.length == 4) { + FocusScope.of(context).requestFocus(_monthFocus); + } + }, ), ), SizedBox(width: 10), @@ -948,6 +958,16 @@ class _PetDetailScreenState extends State { controller: _monthController, hint: 'MM', keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + DateRangeInputFormatter(min: 1, max: 12), + ], + onChanged: (value) { + if (value.length == 2) { + FocusScope.of(context).requestFocus(_dayFocus); + } + }, ), ), SizedBox(width: 10), @@ -956,6 +976,14 @@ class _PetDetailScreenState extends State { controller: _dayController, hint: 'DD', keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + DayInputFormatter( + monthController: _monthController, + yearController: _yearController, + ), + ], ), ), ], @@ -987,6 +1015,10 @@ class _PetDetailScreenState extends State { controller: _registrationNumberController, hint: '숫자만 입력', keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(15), + ], ), SizedBox(height: 24), @@ -996,6 +1028,9 @@ class _PetDetailScreenState extends State { hint: '예: 4.5', suffix: 'kg', keyboardType: TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], ), SizedBox(height: 24), @@ -1115,12 +1150,18 @@ class _PetDetailScreenState extends State { VoidCallback? onTap, TextInputType? keyboardType, String? suffix, + List? inputFormatters, + FocusNode? focusNode, // Added to support focusing logic + ValueChanged? onChanged, // Added to support focus change }) { return TextField( controller: controller, readOnly: readOnly, onTap: onTap, keyboardType: keyboardType, + inputFormatters: inputFormatters, // Added + focusNode: focusNode, // Added + onChanged: onChanged, // Added decoration: InputDecoration( hintText: hint, suffixText: suffix, diff --git a/app/lib/screens/pet_form_screen.dart b/app/lib/screens/pet_form_screen.dart index d4555c4..0ed1604 100644 --- a/app/lib/screens/pet_form_screen.dart +++ b/app/lib/screens/pet_form_screen.dart @@ -8,6 +8,9 @@ import '../theme/app_colors.dart'; import '../data/pet_data.dart'; import '../services/firestore_service.dart'; import '../models/pet_model.dart'; +import '../services/api_service.dart'; +import '../services/auth_service.dart'; +import '../widgets/pet_registration/input_formatters.dart'; class PetFormScreen extends StatefulWidget { final Pet? petToEdit; @@ -62,7 +65,61 @@ class _PetFormScreenState extends State { _monthController.addListener(_updateState); _dayController.addListener(_updateState); _weightController.addListener(_updateState); + _weightController.addListener(_updateState); _registrationNumberController.addListener(_updateState); // 추가 + _loadMasterData(); + } + + // API로부터 마스터 데이터 로드 + Map>> _breedsData = {}; + List _diseaseList = []; + bool _isMasterDataLoaded = false; + + Future _loadMasterData() async { + try { + final apiData = await ApiService().getInitialData(); + final groups = apiData['groups'] as List; + final diseases = apiData['diseases'] as List; + + // Process Diseases + _diseaseList = diseases.map((e) => e['name'] as String).toList(); + if (!_diseaseList.contains('기타')) _diseaseList.add('기타'); + + // Process Groups -> Species -> Breeds + _breedsData = {}; + for (var group in groups) { + Map> speciesMap = {}; + for (var species in group['PetSpecies']) { + List breeds = (species['PetBreeds'] as List) + .map((b) => b['name'] as String) + .toList(); + if (!breeds.contains('기타(직접 입력)')) breeds.add('기타(직접 입력)'); + speciesMap[species['name']] = breeds; + } + speciesMap['기타(직접 입력)'] = ['기타(직접 입력)']; + _breedsData[group['name']] = speciesMap; + } + _breedsData['기타(직접 입력)'] = { + '기타(직접 입력)': ['기타(직접 입력)'], + }; + + if (mounted) { + setState(() { + _isMasterDataLoaded = true; + + // 마스터 데이터 로드 후 카테고리 정보 복원 시도 (초기화 시 못 찾았을 경우 대비) + if (widget.petToEdit != null && + (_currentMajorCategory == null || + _currentMinorCategory == null)) { + _restoreCategoryFromSpecies(_speciesController.text); + } + }); + } + } catch (e) { + debugPrint('Error loading master data: $e'); + // Fallback or retry logic can be added here + // For now, allow fallback to PetData if empty, or just show loading + } } void _initializeData(Pet pet) { @@ -112,6 +169,29 @@ class _PetFormScreenState extends State { _otherHealthConcernText = o; _healthConcernController.text = text; }); + + // 5. 카테고리 정보 복원 + _restoreCategoryFromSpecies(pet.species); + } + + void _restoreCategoryFromSpecies(String speciesName) { + if (speciesName.isEmpty) return; + + final sourceData = _isMasterDataLoaded ? _breedsData : PetData.breedsData; + + // 이미 설정되어 있다면 패스 + if (_currentMajorCategory != null && _currentMinorCategory != null) return; + + for (var major in sourceData.keys) { + final minorMap = sourceData[major]; + if (minorMap != null && minorMap.containsKey(speciesName)) { + setState(() { + _currentMajorCategory = major; + _currentMinorCategory = speciesName; + }); + return; + } + } } void _parseAndSetDiseases( @@ -122,6 +202,9 @@ class _PetFormScreenState extends State { String otherText = ''; for (var item in source) { + if (item.trim().isEmpty || item == '[]' || item.contains('[')) + continue; // Filter out bad data + if (item.startsWith('기타(') && item.endsWith(')')) { selected.add('기타'); otherText = item.substring(3, item.length - 1); @@ -170,11 +253,10 @@ class _PetFormScreenState extends State { context: context, backgroundColor: Colors.transparent, builder: (context) { - return Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), + return Material( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + clipBehavior: Clip.hardEdge, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -187,7 +269,10 @@ class _PetFormScreenState extends State { fontWeight: FontWeight.bold, ), ), - SizedBox(height: 20.h), + + SizedBox(height: 10.h), + Divider(color: const Color(0xFFDDDDDD), thickness: 1.h), + SizedBox(height: 10.h), ListTile( leading: Icon( Icons.camera_alt, @@ -324,9 +409,9 @@ class _PetFormScreenState extends State { // 수정 모드 if (widget.petToEdit != null) { - final updatedPet = Pet( - id: widget.petToEdit!.id, - ownerId: widget.petToEdit!.ownerId, + // API 사용 (MySQL) + await ApiService().updatePet( + petId: int.parse(widget.petToEdit!.id), name: _nameController.text, species: _speciesController.text, breed: _breedController.text, @@ -334,22 +419,18 @@ class _PetFormScreenState extends State { isNeutered: _isNeutered, birthDate: birthDate, isDateUnknown: _isDateUnknown, - registrationNumber: _registrationNumberController.text.isNotEmpty - ? _registrationNumberController.text - : null, - profileImageUrl: - widget.petToEdit!.profileImageUrl, // Service에서 업데이트 처리 weight: _weightController.text.isNotEmpty ? double.tryParse(_weightController.text) : null, + registrationNumber: _registrationNumberController.text.isNotEmpty + ? _registrationNumberController.text + : null, + profileImage: _profileImage, diseases: finalDiseases, pastDiseases: finalPastDiseases, healthConcerns: finalHealthConcerns, - createdAt: widget.petToEdit!.createdAt, ); - await firestoreService.updatePet(updatedPet, _profileImage); - if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -372,13 +453,18 @@ class _PetFormScreenState extends State { duration: const Duration(seconds: 2), ), ); - Navigator.pop(context); + Navigator.pop(context, true); // Return true to indicate success } // 등록 모드 else { - final newPet = Pet( - id: firestoreService.generatePetId(), - ownerId: userId, + // API 사용 (MySQL) + final userInfo = await AuthService().getUserInfo(); + if (userInfo == null) { + throw Exception('로그인 정보를 가져올 수 없습니다.'); + } + + await ApiService().registerPet( + userId: userInfo['id'], name: _nameController.text, species: _speciesController.text, breed: _breedController.text, @@ -386,23 +472,20 @@ class _PetFormScreenState extends State { isNeutered: _isNeutered, birthDate: birthDate, isDateUnknown: _isDateUnknown, - registrationNumber: _registrationNumberController.text.isNotEmpty - ? _registrationNumberController.text - : null, - profileImageUrl: null, // Service에서 처리 weight: _weightController.text.isNotEmpty ? double.tryParse(_weightController.text) : null, + registrationNumber: _registrationNumberController.text.isNotEmpty + ? _registrationNumberController.text + : null, + profileImage: _profileImage, diseases: finalDiseases, pastDiseases: finalPastDiseases, healthConcerns: finalHealthConcerns, - createdAt: DateTime.now(), ); - await firestoreService.registerPet(newPet, _profileImage); - if (!mounted) return; - Navigator.pushReplacementNamed(context, '/register_complete'); + Navigator.pop(context, true); // Return true to indicate success } } catch (e) { if (!mounted) return; @@ -469,10 +552,21 @@ class _PetFormScreenState extends State { Divider(color: const Color(0xFFEEEEEE), thickness: 1.h), Expanded( child: ListView.builder( - itemCount: PetData.diseaseList.length, + itemCount: + (_diseaseList.isEmpty + ? PetData.diseaseList + : _diseaseList) + .where((e) => e != '기타(직접 입력)') + .length, padding: const EdgeInsets.symmetric(vertical: 10), itemBuilder: (context, index) { - final disease = PetData.diseaseList[index]; + final originalList = _diseaseList.isEmpty + ? PetData.diseaseList + : _diseaseList; + final filteredList = originalList + .where((e) => e != '기타(직접 입력)') + .toList(); + final disease = filteredList[index]; final isSelected = tempSelected.contains(disease); return Column( children: [ @@ -502,7 +596,7 @@ class _PetFormScreenState extends State { const SizedBox(width: 12), Expanded( child: Text( - disease, + disease == '기타' ? '기타(직접 입력)' : disease, style: TextStyle( fontFamily: 'SCDream', fontSize: 16, @@ -538,6 +632,11 @@ class _PetFormScreenState extends State { horizontal: 10.w, ), border: const OutlineInputBorder(), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide( + color: AppColors.highlight, + ), + ), ), ), ), @@ -629,6 +728,7 @@ class _PetFormScreenState extends State { ); } + // 종 선택 모달 (대분류 -> 중분류 2단계) void _showSpeciesSelectionModal() { showModalBottomSheet( context: context, @@ -643,8 +743,20 @@ class _PetFormScreenState extends State { return StatefulBuilder( builder: (BuildContext context, StateSetter setModalState) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final sourceMap = _isMasterDataLoaded + ? _breedsData + : PetData.breedsData; + final sortedKeys = sourceMap.keys.toList() + ..sort((a, b) { + if (a == '기타(직접 입력)') return 1; + if (b == '기타(직접 입력)') return -1; + return (sourceMap[b]?.length ?? 0).compareTo( + sourceMap[a]?.length ?? 0, + ); + }); + return Container( - height: 0.6.sh, + height: 0.6.sh, // 높이 60%로 조정 margin: EdgeInsets.only(top: 50.h), decoration: BoxDecoration( color: Colors.white, @@ -652,6 +764,7 @@ class _PetFormScreenState extends State { ), child: Column( children: [ + // 상단 네비게이션바 (닫기 / 뒤로가기) Padding( padding: EdgeInsets.symmetric( horizontal: 16.w, @@ -660,14 +773,18 @@ class _PetFormScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + // 뒤로가기 버튼 (selectedMajor != null || showInput) ? GestureDetector( - onTap: () => setModalState(() { - if (showInput) - showInput = false; - else - selectedMajor = null; - }), + onTap: () { + setModalState(() { + if (showInput) { + showInput = false; + } else { + selectedMajor = null; + } + }); + }, child: Icon( Icons.arrow_back_ios, size: 20.w, @@ -675,6 +792,7 @@ class _PetFormScreenState extends State { ), ) : SizedBox(width: 20.w), + // 타이틀 Text( showInput ? '직접 입력' @@ -683,8 +801,10 @@ class _PetFormScreenState extends State { fontFamily: 'SCDream', fontSize: 18.sp, fontWeight: FontWeight.bold, + color: Colors.black, ), ), + // 닫기 버튼 GestureDetector( onTap: () => Navigator.pop(context), child: Icon( @@ -697,6 +817,8 @@ class _PetFormScreenState extends State { ), ), Divider(color: const Color(0xFFEEEEEE), thickness: 1.h), + + // 컨텐츠 영역 Expanded( child: showInput ? Padding( @@ -708,6 +830,7 @@ class _PetFormScreenState extends State { style: TextStyle( fontFamily: 'SCDream', fontSize: 16.sp, + color: Colors.black87, ), ), SizedBox(height: 20.h), @@ -715,11 +838,16 @@ class _PetFormScreenState extends State { controller: speciesInputController, autofocus: true, decoration: const InputDecoration( - hintText: '예: 미어캣', + hintText: '예: 미어캣, 라쿤 등', border: OutlineInputBorder(), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: AppColors.highlight, + ), + ), ), ), - Spacer(), + const Spacer(), SizedBox( width: double.infinity, height: 52.h, @@ -731,6 +859,7 @@ class _PetFormScreenState extends State { setState(() { _speciesController.text = speciesInputController.text; + // 직접 입력 시 카테고리 정보 초기화 (품종 선택 불가 또는 직접 입력) _currentMajorCategory = null; _currentMinorCategory = null; _breedController.clear(); @@ -740,6 +869,7 @@ class _PetFormScreenState extends State { }, style: ElevatedButton.styleFrom( backgroundColor: AppColors.highlight, + elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( 12.r, @@ -757,68 +887,92 @@ class _PetFormScreenState extends State { ), ), ), + SizedBox(height: bottomInset), ], ), ) - : ListView.builder( - itemCount: selectedMajor == null - ? PetData.breedsData.keys.length - : PetData.breedsData[selectedMajor]!.length, - itemBuilder: (context, index) { - if (selectedMajor == null) { - final major = PetData.breedsData.keys.elementAt( - index, - ); - return ListTile( - title: Text( - major, - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 16, - ), - ), - trailing: Icon( - Icons.arrow_forward_ios, - size: 16, - color: Colors.grey, - ), - onTap: () => setModalState(() { - if (major == '기타(직접 입력)') - showInput = true; - else - selectedMajor = major; - }), - ); - } else { - final minor = PetData - .breedsData[selectedMajor]! - .keys - .elementAt(index); - return ListTile( - title: Text( - minor, - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 16, - ), - ), - onTap: () { - if (minor == '기타(직접 입력)') - setModalState(() => showInput = true); - else { - setState(() { - _currentMajorCategory = selectedMajor; - _currentMinorCategory = minor; - _speciesController.text = minor; - _breedController.clear(); - }); - Navigator.pop(context); - } + : (selectedMajor == null + ? ListView.builder( + // 대분류 리스트 + itemCount: sortedKeys.length, + itemBuilder: (context, index) { + final major = sortedKeys[index]; + return ListTile( + title: Text( + major, + style: const TextStyle( + fontFamily: 'SCDream', + fontSize: 16, + ), + ), + trailing: const Icon( + Icons.arrow_forward_ios, + size: 16, + color: Colors.grey, + ), + onTap: () { + setModalState(() { + if (major == '기타(직접 입력)') { + showInput = true; + } else { + selectedMajor = major; + } + }); + }, + ); }, - ); - } - }, - ), + ) + : ListView.builder( + // 중분류 리스트 + itemCount: _isMasterDataLoaded + ? _breedsData[selectedMajor]!.length + : PetData + .breedsData[selectedMajor]! + .length, + itemBuilder: (context, index) { + final minor = _isMasterDataLoaded + ? _breedsData[selectedMajor]!.keys + .elementAt(index) + : PetData + .breedsData[selectedMajor]! + .keys + .elementAt(index); + return ListTile( + title: Text( + minor, + style: const TextStyle( + fontFamily: 'SCDream', + fontSize: 16, + ), + ), + trailing: minor == '기타(직접 입력)' + ? const Icon( + Icons.arrow_forward_ios, + size: 16, + color: Colors.grey, + ) + : null, + onTap: () { + if (minor == '기타(직접 입력)') { + setModalState(() { + showInput = true; + }); + } else { + setState(() { + // 최종 선택 반영 + _currentMajorCategory = + selectedMajor; + _currentMinorCategory = minor; + _speciesController.text = minor; + _breedController + .clear(); // 종 변경 시 품종 초기화 + }); + Navigator.pop(context); + } + }, + ); + }, + )), ), ], ), @@ -829,22 +983,45 @@ class _PetFormScreenState extends State { ); } + // 품종 선택 모달 (검색 가능) void _showBreedSelectionModal() { + // 1. 종 선택 선행 확인 if (_speciesController.text.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('반려동물 종을 먼저 선택해주세요.'), - duration: Duration(seconds: 1), + SnackBar( + content: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.white), + SizedBox(width: 10.w), + const Text( + '반려동물 종을 먼저 선택해주세요.', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + backgroundColor: AppColors.highlight, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.r), + ), + margin: EdgeInsets.all(20.w), + duration: const Duration(seconds: 2), ), ); return; } + + // 2. 직접 입력 등 카테고리 정보가 없는 경우 -> 바로 직접 입력 모드로 if (_currentMajorCategory == null || _currentMinorCategory == null) { _showBreedDirectInputModal(); return; } - final List originalList = PetData - .breedsData[_currentMajorCategory]![_currentMinorCategory]! + + // 3. 품종 리스트 가져오기 + final Map> sourceData = _isMasterDataLoaded + ? _breedsData[_currentMajorCategory]! + : PetData.breedsData[_currentMajorCategory]!; + final List originalList = sourceData[_currentMinorCategory]! .where((e) => e != '기타(직접 입력)') .toList(); @@ -863,6 +1040,23 @@ class _PetFormScreenState extends State { return StatefulBuilder( builder: (BuildContext context, StateSetter setModalState) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; + + void filterList(String query) { + setModalState(() { + searchText = query; + if (query.isEmpty) { + filteredList = List.from(originalList); + } else { + filteredList = originalList + .where( + (breed) => + breed.toLowerCase().contains(query.toLowerCase()), + ) + .toList(); + } + }); + } + return Container( height: 0.85.sh, margin: EdgeInsets.only(top: 50.h), @@ -872,6 +1066,7 @@ class _PetFormScreenState extends State { ), child: Column( children: [ + // 상단 네비게이션바 Padding( padding: EdgeInsets.symmetric( horizontal: 16.w, @@ -882,100 +1077,191 @@ class _PetFormScreenState extends State { children: [ showInput ? GestureDetector( - onTap: () => - setModalState(() => showInput = false), - child: Icon(Icons.arrow_back_ios), + onTap: () { + setModalState(() { + showInput = false; + }); + }, + child: Icon( + Icons.arrow_back_ios, + size: 20.w, + color: Colors.black, + ), ) - : SizedBox(width: 20), + : const SizedBox(width: 20), Text( showInput ? '직접 입력' : '품종 선택', style: TextStyle( + fontFamily: 'SCDream', fontSize: 18.sp, fontWeight: FontWeight.bold, + color: Colors.black, ), ), GestureDetector( onTap: () => Navigator.pop(context), - child: Icon(Icons.close), + child: Icon( + Icons.close, + size: 24.w, + color: Colors.black, + ), ), ], ), ), - Divider(), + Divider(color: const Color(0xFFEEEEEE), thickness: 1.h), + + // 컨텐츠 Expanded( child: showInput ? Padding( padding: EdgeInsets.all(20.w), child: Column( children: [ + Text( + '반려동물의 품종을 직접 입력해주세요.', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 16.sp, + color: Colors.black87, + ), + ), + SizedBox(height: 20.h), TextField( controller: manualInputController, autofocus: true, - decoration: InputDecoration( - hintText: '예: 믹스', + decoration: const InputDecoration( + hintText: '예: 믹스, 시고르자브종 등', + border: OutlineInputBorder(), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: AppColors.highlight, + ), + ), ), ), - Spacer(), - ElevatedButton( - onPressed: () { - if (manualInputController.text.isNotEmpty) { - setState( - () => _breedController.text = - manualInputController.text, - ); - Navigator.pop(context); - } - }, - child: Text('완료'), + const Spacer(), + SizedBox( + width: double.infinity, + height: 52.h, + child: ElevatedButton( + onPressed: () { + if (manualInputController + .text + .isNotEmpty) { + setState(() { + _breedController.text = + manualInputController.text; + }); + Navigator.pop(context); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.highlight, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 12.r, + ), + ), + ), + child: Text( + '완료', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 16.sp, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), ), + SizedBox(height: bottomInset), ], ), ) : Column( children: [ + // 검색창 Padding( - padding: EdgeInsets.all(10), + padding: EdgeInsets.fromLTRB( + 20.w, + 10.h, + 20.w, + 10.h, + ), child: TextField( controller: searchController, - onChanged: (v) { - setModalState(() { - searchText = v; - filteredList = v.isEmpty - ? List.from(originalList) - : originalList - .where((b) => b.contains(v)) - .toList(); - }); - }, + onChanged: filterList, decoration: InputDecoration( - hintText: '검색', - prefixIcon: Icon(Icons.search), + hintText: '품종 검색', + prefixIcon: const Icon( + Icons.search, + color: Colors.grey, + ), filled: true, - border: OutlineInputBorder( + fillColor: Colors.white, + enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, + borderSide: const BorderSide( + color: AppColors.highlight, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: AppColors.highlight, + ), + ), + contentPadding: const EdgeInsets.symmetric( + vertical: 0, + horizontal: 16, ), ), ), ), + // 리스트 Expanded( child: ListView.builder( - itemCount: filteredList.length + 1, - itemBuilder: (ctx, idx) { - if (idx == filteredList.length) + itemCount: + filteredList.length + 1, // 목록 + 직접입력 + itemBuilder: (context, index) { + if (index == filteredList.length) { + // 마지막 아이템: 직접 입력 return ListTile( - title: Text('기타(직접 입력)'), - onTap: () => setModalState( - () => showInput = true, + title: const Text( + '기타(직접 입력)', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 16, + color: Colors.black87, + ), ), + trailing: const Icon( + Icons.arrow_forward_ios, + size: 16, + color: Colors.grey, + ), + onTap: () { + setModalState(() { + showInput = true; + }); + }, ); + } + final breed = filteredList[index]; return ListTile( - title: Text(filteredList[idx]), + title: Text( + breed, + style: const TextStyle( + fontFamily: 'SCDream', + fontSize: 16, + ), + ), onTap: () { - setState( - () => _breedController.text = - filteredList[idx], - ); + setState(() { + _breedController.text = breed; + }); Navigator.pop(context); }, ); @@ -994,85 +1280,155 @@ class _PetFormScreenState extends State { ); } + // 품종 직접 입력 모달 (카테고리 정보 없을 때) void _showBreedDirectInputModal() { showModalBottomSheet( context: context, - isScrollControlled: true, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (context) { - final controller = TextEditingController(); + final TextEditingController manualInputController = + TextEditingController(); return Container( - height: 0.5.sh, + height: 0.85.sh, + margin: EdgeInsets.only(top: 50.h), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), ), - padding: EdgeInsets.all(20), - child: Column( - children: [ - Text( - '품종 입력', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - TextField( - controller: controller, - decoration: InputDecoration(hintText: '품종을 입력하세요'), - ), - ElevatedButton( - onPressed: () { - setState(() => _breedController.text = controller.text); - Navigator.pop(context); - }, - child: Text('완료'), - ), - ], + child: Padding( + padding: EdgeInsets.all(20.w), + child: Column( + children: [ + // 네비게이션 + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon(Icons.close, size: 24.w, color: Colors.black), + ), + ], + ), + SizedBox(height: 20.h), + Text( + '반려동물의 품종을 직접 입력해주세요.', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 16.sp, + color: Colors.black87, + ), + ), + SizedBox(height: 20.h), + TextField( + controller: manualInputController, + autofocus: true, + decoration: const InputDecoration( + hintText: '예: 믹스, 시고르자브종 등', + border: OutlineInputBorder(), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.highlight), + ), + ), + ), + const Spacer(), + SizedBox( + width: double.infinity, + height: 52.h, + child: ElevatedButton( + onPressed: () { + if (manualInputController.text.isNotEmpty) { + setState(() { + _breedController.text = manualInputController.text; + }); + Navigator.pop(context); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.highlight, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + ), + child: Text( + '완료', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 16.sp, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + SizedBox(height: MediaQuery.of(context).viewInsets.bottom), + ], + ), ), ); }, ); } + // 성별 선택 모달 (남아/여아/기타 + 중성화) void _showGenderSelectionModal() { showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (context) { + // 모달 내부 임시 상태 String? tempGender = _selectedGender; bool tempNeutered = _isNeutered; + return StatefulBuilder( - builder: (context, setModalState) { + builder: (BuildContext context, StateSetter setModalState) { return Container( + // height 제거 (내용물 크기에 맞춤) decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), ), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.min, // 내용물만큼만 차지 children: [ + // 상단 네비게이션바 Padding( - padding: EdgeInsets.all(16), + padding: EdgeInsets.symmetric( + horizontal: 16.w, + vertical: 12.h, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - SizedBox(width: 24), + SizedBox(width: 24.w), // 닫기 버튼과 대칭을 위한 여백 Text( '성별 선택', style: TextStyle( - fontSize: 18, + fontFamily: 'SCDream', + fontSize: 18.sp, fontWeight: FontWeight.bold, + color: Colors.black, ), ), GestureDetector( onTap: () => Navigator.pop(context), - child: Icon(Icons.close), + child: Icon( + Icons.close, + size: 24.w, + color: Colors.black, + ), ), ], ), ), - Divider(), + Divider(color: const Color(0xFFEEEEEE), thickness: 1.h), + SizedBox(height: 30.h), + + // 성별 선택 버튼 영역 (3개) Padding( - padding: EdgeInsets.all(20), + padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( children: [ Expanded( @@ -1080,33 +1436,39 @@ class _PetFormScreenState extends State { '남아', Icons.male, tempGender == '남아', - (v) => setModalState(() => tempGender = v), + (val) => setModalState(() => tempGender = val), ), ), - SizedBox(width: 12), + const SizedBox(width: 12), Expanded( child: _buildGenderCard( '여아', Icons.female, tempGender == '여아', - (v) => setModalState(() => tempGender = v), + (val) => setModalState(() => tempGender = val), ), ), - SizedBox(width: 12), + const SizedBox(width: 12), Expanded( child: _buildGenderCard( '기타', Icons.question_mark, tempGender == '기타', - (v) => setModalState(() => tempGender = v), + (val) => setModalState(() => tempGender = val), ), ), ], ), ), + const SizedBox(height: 30), + + // 중성화 여부 체크박스 GestureDetector( - onTap: () => - setModalState(() => tempNeutered = !tempNeutered), + onTap: () { + setModalState(() { + tempNeutered = !tempNeutered; + }); + }, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -1117,36 +1479,57 @@ class _PetFormScreenState extends State { color: tempNeutered ? AppColors.highlight : Colors.grey, + size: 24.w, + ), + SizedBox(width: 8.w), + Text( + '중성화를 했어요', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 16.sp, + color: Colors.black87, + ), ), - SizedBox(width: 8), - Text('중성화를 했어요'), ], ), ), + SizedBox(height: 12.h), // 간격 축소 + // 완료 버튼 Padding( - padding: EdgeInsets.all(20), + padding: EdgeInsets.fromLTRB(20.w, 0, 20.w, 20.h), child: SizedBox( width: double.infinity, - height: 52, + height: 52.h, child: ElevatedButton( onPressed: () { setState(() { _selectedGender = tempGender; _isNeutered = tempNeutered; - _genderController.text = _selectedGender == '기타' - ? '기타' - : '$_selectedGender${_isNeutered ? "(중성화)" : ""}'; + if (_selectedGender != null) { + if (_selectedGender == '기타') { + _genderController.text = '기타'; + } else { + _genderController.text = + '$_selectedGender${_isNeutered ? '(중성화)' : ''}'; + } + } }); Navigator.pop(context); }, style: ElevatedButton.styleFrom( backgroundColor: AppColors.highlight, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), ), - child: Text( + child: const Text( '선택 완료', style: TextStyle( - color: Colors.white, + fontFamily: 'SCDream', + fontSize: 16, fontWeight: FontWeight.bold, + color: Colors.white, ), ), ), @@ -1170,28 +1553,32 @@ class _PetFormScreenState extends State { return GestureDetector( onTap: () => onTap(gender), child: Container( - height: 100, + width: 120.w, + height: 100.h, decoration: BoxDecoration( color: isSelected ? AppColors.highlight.withOpacity(0.1) : Colors.white, + borderRadius: BorderRadius.circular(16.r), border: Border.all( - color: isSelected ? AppColors.highlight : Color(0xFFEEEEEE), + color: isSelected ? AppColors.highlight : const Color(0xFFEEEEEE), + width: 2.w, ), - borderRadius: BorderRadius.circular(16), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( icon, - size: 40, + size: 40.w, color: isSelected ? AppColors.highlight : Colors.grey, ), - SizedBox(height: 12), + SizedBox(height: 12.h), Text( gender, style: TextStyle( + fontFamily: 'SCDream', + fontSize: 16.sp, fontWeight: FontWeight.bold, color: isSelected ? AppColors.highlight : Colors.grey, ), @@ -1445,6 +1832,7 @@ class _PetFormScreenState extends State { const SizedBox(height: 24), // 5. 동물 등록 번호 + _buildLabel('동물 등록 번호', isRequired: false), _buildTextField( controller: _registrationNumberController, hint: '숫자만 입력', @@ -1722,86 +2110,3 @@ class _PetFormScreenState extends State { ); } } - -// --- Helper Classes (Restored) --- - -class DateRangeInputFormatter extends TextInputFormatter { - final int min; - final int max; - - DateRangeInputFormatter({required this.min, required this.max}); - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - if (newValue.text.isEmpty) { - return newValue; - } - - final int? value = int.tryParse(newValue.text); - if (value == null) { - return oldValue; - } - - if (value < min || value > max) { - return oldValue; - } - - return newValue; - } -} - -class DayInputFormatter extends TextInputFormatter { - final TextEditingController monthController; - final TextEditingController yearController; - - DayInputFormatter({ - required this.monthController, - required this.yearController, - }); - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - if (newValue.text.isEmpty) { - return newValue; - } - - final int? day = int.tryParse(newValue.text); - if (day == null) { - return oldValue; - } - - int? month = int.tryParse(monthController.text); - int? year = int.tryParse(yearController.text); - - if (month == null || month < 1 || month > 12) { - // 월이 입력되지 않았거나 유효하지 않으면 31일까지 허용 - if (day < 1 || day > 31) return oldValue; - return newValue; - } - - int maxDay = _getDaysInMonth(year, month); - if (day < 1 || day > maxDay) { - return oldValue; - } - - return newValue; - } - - int _getDaysInMonth(int? year, int month) { - if (month == 2) { - if (year != null && - ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))) { - return 29; - } - return 28; - } - const daysInMonth = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - return daysInMonth[month]; - } -} diff --git a/app/lib/services/api_service.dart b/app/lib/services/api_service.dart new file mode 100644 index 0000000..5db38ef --- /dev/null +++ b/app/lib/services/api_service.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +class ApiService { + // Use 10.0.2.2 for Android Emulator, localhost for iOS/Web + static final String baseUrl = Platform.isAndroid + ? 'http://10.0.2.2:3000' + : 'http://localhost:3000'; + + final Dio _dio = Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ), + ); + + // Get Initial Master Data (Groups, Species, Breeds, Diseases) + Future> getInitialData() async { + try { + final response = await _dio.get('/common/initial-data'); + return response.data; + } catch (e) { + debugPrint('Error fetching initial data: $e'); + rethrow; + } + } + + // Register Pet + Future> registerPet({ + required int userId, + required String name, + required String species, + required String breed, + required String gender, + required bool isNeutered, + DateTime? birthDate, + required bool isDateUnknown, + double? weight, + String? registrationNumber, + File? profileImage, + List? diseases, + List? pastDiseases, + List? healthConcerns, + }) async { + try { + final formData = FormData.fromMap({ + 'userId': userId, + 'name': name, + 'species': species, + 'breed': breed, + 'gender': gender, // "남아", "여아", "기타" + 'isNeutered': isNeutered, + 'birthDate': birthDate?.toIso8601String(), + 'isDateUnknown': isDateUnknown, + 'weight': weight, + 'registrationNumber': registrationNumber, + 'diseases': diseases != null ? jsonEncode(diseases) : '[]', + 'pastDiseases': pastDiseases != null ? jsonEncode(pastDiseases) : '[]', + 'healthConcerns': healthConcerns != null + ? jsonEncode(healthConcerns) + : '[]', + if (profileImage != null) + 'profileImage': await MultipartFile.fromFile( + profileImage.path, + filename: profileImage.path.split('/').last, + ), + }); + + final response = await _dio.post('/pets', data: formData); + return response.data; + } catch (e) { + debugPrint('Error registering pet: $e'); + rethrow; + } + } + + // Update Pet + Future> updatePet({ + required int petId, + required String name, + required String species, + required String breed, + required String gender, + required bool isNeutered, + DateTime? birthDate, + required bool isDateUnknown, + double? weight, + String? registrationNumber, + File? profileImage, + List? diseases, + List? pastDiseases, + List? healthConcerns, + }) async { + try { + final formData = FormData.fromMap({ + 'name': name, + 'species': species, + 'breed': breed, + 'gender': gender, + 'isNeutered': isNeutered, + 'birthDate': birthDate?.toIso8601String(), + 'isDateUnknown': isDateUnknown, + 'weight': weight, + 'registrationNumber': registrationNumber, + 'diseases': diseases != null ? jsonEncode(diseases) : '[]', + 'pastDiseases': pastDiseases != null ? jsonEncode(pastDiseases) : '[]', + 'healthConcerns': healthConcerns != null + ? jsonEncode(healthConcerns) + : '[]', + if (profileImage != null) + 'profileImage': await MultipartFile.fromFile( + profileImage.path, + filename: profileImage.path.split('/').last, + ), + }); + + final response = await _dio.put('/pets/$petId', data: formData); + return response.data; + } catch (e) { + debugPrint('Error updating pet: $e'); + rethrow; + } + } + + // Get Pets + Future> getPets(int userId) async { + try { + final response = await _dio.get( + '/pets', + queryParameters: {'userId': userId}, + ); + return response.data; + } catch (e) { + debugPrint('Error fetching pets: $e'); + rethrow; + } + } +} diff --git a/app/lib/widgets/home/pet_profile_card.dart b/app/lib/widgets/home/pet_profile_card.dart index 3ecbd2a..72ba61b 100644 --- a/app/lib/widgets/home/pet_profile_card.dart +++ b/app/lib/widgets/home/pet_profile_card.dart @@ -8,25 +8,26 @@ import '../../screens/pet_form_screen.dart'; class PetProfileCard extends StatelessWidget { final Pet pet; + final VoidCallback? onPetUpdated; - const PetProfileCard({super.key, required this.pet}); + const PetProfileCard({super.key, required this.pet, this.onPetUpdated}); // 나이 계산 (만 나이 & 사람 나이 환산 - 단순 예시) String _calculateAge(DateTime? birthDate) { - if (birthDate == null) return '알 수 없음'; + if (birthDate == null) return '??살'; final now = DateTime.now(); int age = now.year - birthDate.year; if (now.month < birthDate.month || (now.month == birthDate.month && now.day < birthDate.day)) { age--; } - return '$age세'; + return '$age살'; } // 사람 나이 환산 (강아지 기준 대략적 계산 - 소형견 기준 예시) // 사람 나이 환산 (종별 계산법 적용) String _calculateHumanAge(DateTime? birthDate, String species) { - if (birthDate == null) return '??세'; + if (birthDate == null) return '??살'; final now = DateTime.now(); int ageYears = now.year - birthDate.year; @@ -117,7 +118,7 @@ class PetProfileCard extends StatelessWidget { break; } - return '$humanAge세'; + return '$humanAge살'; } @override @@ -146,7 +147,6 @@ class PetProfileCard extends StatelessWidget { // 왼쪽: 프로필 이미지 Container( width: 120.w, - // height 제거 (stretch 되도록) decoration: BoxDecoration( borderRadius: BorderRadius.circular(16.r), color: Colors.grey[200], @@ -176,7 +176,6 @@ class PetProfileCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 이름 & 종 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -208,8 +207,6 @@ class PetProfileCard extends StatelessWidget { ], ), ), - // 상세 프로필 이동 (우측 상단) - // 상세 프로필 이동 (우측 상단) Material( color: Colors.transparent, child: InkWell( @@ -221,7 +218,11 @@ class PetProfileCard extends StatelessWidget { builder: (context) => PetFormScreen(petToEdit: pet), ), - ); + ).then((value) { + if (value == true && onPetUpdated != null) { + onPetUpdated!(); + } + }); }, child: Padding( padding: EdgeInsets.all(8.w), @@ -244,7 +245,7 @@ class PetProfileCard extends StatelessWidget { color: Colors.grey[600], ), ), - SizedBox(height: 8.h), // 간격 줄임 (12 -> 8) + SizedBox(height: 8.h), // 정보 박스 (생일, 체중) Row( children: [ @@ -261,12 +262,13 @@ class PetProfileCard extends StatelessWidget { ), ], ), - SizedBox(height: 8.h), // 간격 줄임 (12 -> 8) + SizedBox(height: 8.h), // 나이 정보 FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '나이 ', @@ -274,6 +276,7 @@ class PetProfileCard extends StatelessWidget { fontFamily: 'SCDream', fontSize: 14.sp, color: Colors.grey[600], + height: 1.2, ), ), Text( @@ -283,13 +286,18 @@ class PetProfileCard extends StatelessWidget { fontSize: 15.sp, fontWeight: FontWeight.bold, color: Colors.black, + height: 1.2, ), ), Padding( padding: EdgeInsets.symmetric(horizontal: 8.w), child: Text( '/', - style: TextStyle(color: Colors.grey[300]), + style: TextStyle( + color: Colors.grey[300], + fontSize: 14.sp, + height: 1.2, + ), ), ), Text( @@ -298,14 +306,17 @@ class PetProfileCard extends StatelessWidget { fontFamily: 'SCDream', fontSize: 14.sp, color: Colors.grey[600], + height: 1.2, ), ), Text( - '사람 나이 환산 ${_calculateHumanAge(pet.birthDate, pet.species)}', + _calculateHumanAge(pet.birthDate, pet.species), style: TextStyle( fontFamily: 'SCDream', - fontSize: 12.sp, - color: Colors.white, + fontSize: 15.sp, + fontWeight: FontWeight.bold, + color: Colors.black, + height: 1.2, ), ), ], @@ -323,7 +334,7 @@ class PetProfileCard extends StatelessWidget { fontFamily: 'SCDream', fontSize: 14.sp, color: Colors.grey[600], - height: 1.2, // 줄간격 살짝 조정 + height: 1.2, ), ), Expanded( @@ -365,7 +376,7 @@ class PetProfileCard extends StatelessWidget { child: Container( padding: EdgeInsets.all(8.w), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.4), // 반투명 흰색 배경 + color: Colors.white.withOpacity(0.4), border: Border.all(color: const Color(0xFFEEEEEE), width: 1), borderRadius: BorderRadius.circular(12.r), ), @@ -377,7 +388,7 @@ class PetProfileCard extends StatelessWidget { style: TextStyle( fontFamily: 'SCDream', fontSize: 12.sp, - color: Colors.grey[600], // 가독성을 위해 조금 진하게 + color: Colors.grey[600], ), ), SizedBox(height: 4.h), diff --git a/app/lib/widgets/pet_registration/input_formatters.dart b/app/lib/widgets/pet_registration/input_formatters.dart index b22b33f..1dd50d4 100644 --- a/app/lib/widgets/pet_registration/input_formatters.dart +++ b/app/lib/widgets/pet_registration/input_formatters.dart @@ -20,16 +20,13 @@ class DateRangeInputFormatter extends TextInputFormatter { final int? value = int.tryParse(newValue.text); if (value == null) { - return oldValue; // 숫자가 아닌 경우 이전 값 유지 + return oldValue; } if (value < min || value > max) { - // 범위를 벗어나면 이전 값 유지 (단, 입력 중인 상태 고려 - 예: 3을 입력하려는데 30이 되면 안됨) - // 여기서는 단순하게 max보다 크면 입력 불가 처리 (사용자 경험상 이게 나을 수 있음) if (newValue.text.length > max.toString().length) { return oldValue; } - // 더 정교한 로직이 필요할 수 있으나, 기본적으로 입력 막음 if (value > max) return oldValue; } diff --git a/backend/index.js b/backend/index.js index 9a7eb05..6002ec9 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,7 +1,11 @@ const express = require('express'); const cors = require('cors'); +const path = require('path'); const { connectDB, sequelize } = require('./config/db'); const authRoutes = require('./routes/auth'); +const commonRoutes = require('./routes/common'); +const petRoutes = require('./routes/pets'); +const seedData = require('./scripts/seedData'); const app = express(); const port = 3000; @@ -9,9 +13,15 @@ const port = 3000; // Middleware app.use(cors()); app.use(express.json()); // Body parser for JSON +app.use(express.urlencoded({ extended: true })); // For multipart form-data handling if needed (though multer handles it) + +// Static Files +app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); // Routes app.use('/auth', authRoutes); +app.use('/common', commonRoutes); +app.use('/pets', petRoutes); app.get('/', (req, res) => { res.send('Hello from Express Backend!'); @@ -21,11 +31,14 @@ app.get('/', (req, res) => { const startServer = async () => { await connectDB(); - // Sync models (in production, use migration instead of sync({alter: true})) - // For dev: force: false to keep data, alter: true to update schema + // Sync models + // force: false to keep data, alter: true to update schema await sequelize.sync({ alter: true }); console.log('Database synced'); + // Seed Data + await seedData(); + app.listen(port, '0.0.0.0', () => { console.log(`Backend app listening on port ${port}`); }); diff --git a/backend/manual_sync.js b/backend/manual_sync.js new file mode 100644 index 0000000..b21abc5 --- /dev/null +++ b/backend/manual_sync.js @@ -0,0 +1,26 @@ +const { sequelize } = require('./config/db'); +require('./models/Pet'); // Load models + +const syncDB = async () => { + try { + console.log('Connecting to DB...'); + await sequelize.authenticate(); + console.log('Connected. Syncing schema...'); + + // Force alter to ensure columns are added + await sequelize.sync({ alter: true }); + + console.log('Schema update complete. Checking Pet table columns...'); + + // Check columns + const [results] = await sequelize.query('DESCRIBE Pets;'); + console.log('Table Structure:', results.map(r => r.Field)); + + process.exit(0); + } catch (error) { + console.error('Sync failed:', error); + process.exit(1); + } +}; + +syncDB(); diff --git a/backend/models/Pet.js b/backend/models/Pet.js new file mode 100644 index 0000000..7af5824 --- /dev/null +++ b/backend/models/Pet.js @@ -0,0 +1,78 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/db'); + +const Pet = sequelize.define('Pet', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + comment: 'Reference to User table', + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + species: { + type: DataTypes.STRING, + allowNull: false, + comment: 'Species Name (e.g. 강아지)', + }, + breed: { + type: DataTypes.STRING, + allowNull: false, + comment: 'Breed Name (e.g. 말티즈)', + }, + gender: { + type: DataTypes.STRING, + allowNull: false, + comment: '남아, 여아, 기타', + }, + isNeutered: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + birthDate: { + type: DataTypes.DATEONLY, + allowNull: true, + }, + isDateUnknown: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + weight: { + type: DataTypes.FLOAT, + allowNull: true, + }, + registrationNumber: { + type: DataTypes.STRING, + allowNull: true, + }, + profileImagePath: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Relative path to uploaded image', + }, + diseases: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'JSON string of current diseases', + }, + pastDiseases: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'JSON string of past diseases', + }, + healthConcerns: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'JSON string of health concerns', + }, +}, { + timestamps: true, +}); + +module.exports = Pet; diff --git a/backend/models/PetBreed.js b/backend/models/PetBreed.js new file mode 100644 index 0000000..a3b5725 --- /dev/null +++ b/backend/models/PetBreed.js @@ -0,0 +1,24 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/db'); + +const PetBreed = sequelize.define('PetBreed', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + speciesId: { + type: DataTypes.INTEGER, + allowNull: false, + comment: 'Reference to PetSpecies', + }, + name: { + type: DataTypes.STRING, + allowNull: false, + comment: 'e.g., 말티즈, 푸들', + }, +}, { + timestamps: false, +}); + +module.exports = PetBreed; diff --git a/backend/models/PetDisease.js b/backend/models/PetDisease.js new file mode 100644 index 0000000..7b20740 --- /dev/null +++ b/backend/models/PetDisease.js @@ -0,0 +1,20 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/db'); + +const PetDisease = sequelize.define('PetDisease', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + comment: 'e.g., 피부질환, 눈 질환', + }, +}, { + timestamps: false, +}); + +module.exports = PetDisease; diff --git a/backend/models/PetGroup.js b/backend/models/PetGroup.js new file mode 100644 index 0000000..436ea18 --- /dev/null +++ b/backend/models/PetGroup.js @@ -0,0 +1,20 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/db'); + +const PetGroup = sequelize.define('PetGroup', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + comment: 'e.g., 포유류, 파충류', + }, +}, { + timestamps: false, +}); + +module.exports = PetGroup; diff --git a/backend/models/PetSpecies.js b/backend/models/PetSpecies.js new file mode 100644 index 0000000..2367502 --- /dev/null +++ b/backend/models/PetSpecies.js @@ -0,0 +1,24 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/db'); + +const PetSpecies = sequelize.define('PetSpecies', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + groupId: { + type: DataTypes.INTEGER, + allowNull: false, + comment: 'Reference to PetGroup', + }, + name: { + type: DataTypes.STRING, + allowNull: false, + comment: 'e.g., 강아지, 고양이', + }, +}, { + timestamps: false, +}); + +module.exports = PetSpecies; diff --git a/backend/models/index.js b/backend/models/index.js new file mode 100644 index 0000000..a600c2c --- /dev/null +++ b/backend/models/index.js @@ -0,0 +1,32 @@ +const { sequelize } = require('../config/db'); + +const User = require('./user'); +const PetGroup = require('./PetGroup'); +const PetSpecies = require('./PetSpecies'); +const PetBreed = require('./PetBreed'); +const PetDisease = require('./PetDisease'); +const Pet = require('./Pet'); + +// Associations + +// Master Data Relationships +PetGroup.hasMany(PetSpecies, { foreignKey: 'groupId' }); +PetSpecies.belongsTo(PetGroup, { foreignKey: 'groupId' }); + +PetSpecies.hasMany(PetBreed, { foreignKey: 'speciesId' }); +PetBreed.belongsTo(PetSpecies, { foreignKey: 'speciesId' }); + +// User <-> Pet +User.hasMany(Pet, { foreignKey: 'userId' }); +Pet.belongsTo(User, { foreignKey: 'userId' }); + + +module.exports = { + sequelize, + User, + PetGroup, + PetSpecies, + PetBreed, + PetDisease, + Pet, +}; diff --git a/backend/package.json b/backend/package.json index 432318d..059128e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "google-auth-library": "^9.4.1", "jsonwebtoken": "^9.0.2", "mysql2": "^3.6.5", + "multer": "^1.4.5-lts.1", "sequelize": "^6.35.1" } } \ No newline at end of file diff --git a/backend/routes/common.js b/backend/routes/common.js new file mode 100644 index 0000000..93b4379 --- /dev/null +++ b/backend/routes/common.js @@ -0,0 +1,30 @@ +const express = require('express'); +const router = express.Router(); +const { PetGroup, PetSpecies, PetBreed, PetDisease } = require('../models'); + +// GET /common/initial-data +// Returns hierarchical master data for frontend caching +router.get('/initial-data', async (req, res) => { + try { + const groups = await PetGroup.findAll({ + include: [ + { + model: PetSpecies, + include: [{ model: PetBreed }] + } + ] + }); + + const diseases = await PetDisease.findAll(); + + res.json({ + groups, + diseases, + }); + } catch (error) { + console.error('Error fetching initial data:', error); + res.status(500).json({ message: 'Server Error' }); + } +}); + +module.exports = router; diff --git a/backend/routes/pets.js b/backend/routes/pets.js new file mode 100644 index 0000000..54d5b21 --- /dev/null +++ b/backend/routes/pets.js @@ -0,0 +1,140 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const { Pet, User } = require('../models'); + +// Multer Setup +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + // Use absolute path to ensure it works regardless of CWD + const uploadPath = path.join(__dirname, '../uploads/pets/'); + if (!fs.existsSync(uploadPath)) { + fs.mkdirSync(uploadPath, { recursive: true }); + } + cb(null, uploadPath); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, uniqueSuffix + path.extname(file.originalname)); + } +}); + +const upload = multer({ storage: storage }); + +// POST /pets - Create a new pet with optional image +router.post('/', upload.single('profileImage'), async (req, res) => { + try { + const { + userId, name, species, breed, gender, isNeutered, + birthDate, isDateUnknown, weight, registrationNumber, + diseases, pastDiseases, healthConcerns + } = req.body; + + const profileImagePath = req.file ? `/uploads/pets/${req.file.filename}` : null; + + const newPet = await Pet.create({ + userId, + name, + species, + breed, + gender, + isNeutered: isNeutered === 'true', // Multipart sends strings + birthDate: birthDate ? new Date(birthDate) : null, + isDateUnknown: isDateUnknown === 'true', + weight: weight ? parseFloat(weight) : null, + registrationNumber, + profileImagePath, + diseases: diseases ? diseases : '[]', + pastDiseases: pastDiseases ? pastDiseases : '[]', + healthConcerns: healthConcerns ? healthConcerns : '[]', + }); + + res.status(201).json(newPet); + } catch (error) { + console.error('Error creating pet:', error); + res.status(500).json({ message: error.message, error: error.toString() }); + } +}); + +// GET /pets - Get pets for a specific user (query param ?userId=...) +router.get('/', async (req, res) => { + try { + const { userId } = req.query; + if (!userId) { + return res.status(400).json({ message: 'Missing userId query parameter' }); + } + + const pets = await Pet.findAll({ + where: { userId }, + order: [['createdAt', 'DESC']] + }); + + // Parse JSON strings back to arrays for the response + const parsedPets = pets.map(pet => { + const p = pet.toJSON(); + try { p.diseases = p.diseases ? JSON.parse(p.diseases) : []; } catch (e) { p.diseases = []; } + try { p.pastDiseases = p.pastDiseases ? JSON.parse(p.pastDiseases) : []; } catch (e) { p.pastDiseases = []; } + try { p.healthConcerns = p.healthConcerns ? JSON.parse(p.healthConcerns) : []; } catch (e) { p.healthConcerns = []; } + return p; + }); + + res.json(parsedPets); + } catch (error) { + console.error('Error fetching pets:', error); + res.status(500).json({ message: 'Server Error' }); + } +}); + +// PUT /pets/:id - Update an existing pet +router.put('/:id', upload.single('profileImage'), async (req, res) => { + try { + const { id } = req.params; + const { + name, species, breed, gender, isNeutered, + birthDate, isDateUnknown, weight, registrationNumber, + diseases, pastDiseases, healthConcerns + } = req.body; + + const pet = await Pet.findByPk(id); + if (!pet) { + return res.status(404).json({ message: 'Pet not found' }); + } + + // Only update fields if they are provided + if (name) pet.name = name; + if (species) pet.species = species; + if (breed) pet.breed = breed; + if (gender) pet.gender = gender; + if (isNeutered !== undefined) pet.isNeutered = isNeutered === 'true'; + if (birthDate !== undefined) pet.birthDate = birthDate ? new Date(birthDate) : null; + if (isDateUnknown !== undefined) pet.isDateUnknown = isDateUnknown === 'true'; + if (weight !== undefined) pet.weight = weight ? parseFloat(weight) : null; + if (registrationNumber !== undefined) pet.registrationNumber = registrationNumber; + + if (diseases !== undefined) pet.diseases = diseases ? diseases : '[]'; + if (pastDiseases !== undefined) pet.pastDiseases = pastDiseases ? pastDiseases : '[]'; + if (healthConcerns !== undefined) pet.healthConcerns = healthConcerns ? healthConcerns : '[]'; + + if (req.file) { + pet.profileImagePath = `/uploads/pets/${req.file.filename}`; + // TODO: Delete old image file if exists? + } + + await pet.save(); + + // Parse for response + const p = pet.toJSON(); + try { p.diseases = p.diseases ? JSON.parse(p.diseases) : []; } catch (e) { p.diseases = []; } + try { p.pastDiseases = p.pastDiseases ? JSON.parse(p.pastDiseases) : []; } catch (e) { p.pastDiseases = []; } + try { p.healthConcerns = p.healthConcerns ? JSON.parse(p.healthConcerns) : []; } catch (e) { p.healthConcerns = []; } + + res.json(p); + } catch (error) { + console.error('Error updating pet:', error); + res.status(500).json({ message: error.message, error: error.toString() }); + } +}); + +module.exports = router; diff --git a/backend/scripts/seedData.js b/backend/scripts/seedData.js new file mode 100644 index 0000000..dc2d8d4 --- /dev/null +++ b/backend/scripts/seedData.js @@ -0,0 +1,88 @@ +const { PetGroup, PetSpecies, PetBreed, PetDisease, sequelize } = require('../models'); + +const seedData = async () => { + try { + const groupCount = await PetGroup.count(); + if (groupCount > 0) { + console.log('Master data already exists. Skipping seed.'); + return; + } + + console.log('Seeding Master Data...'); + + // Data from pet_data.dart + const diseaseList = [ + "피부질환", "눈 질환", "치아 / 구강 질환", "뼈 / 관절 질환", "생식기 / 비뇨기 질환", + "심장 / 혈관 질환", "소화기 질환", "호흡기 질환", "내분비계 질환", "뇌신경 질환", + "생식기 질환", "귀 질환", "코 질환", "기타" + ]; + + const breedsData = { + "포유류": { + "강아지": ["말티즈", "푸들", "포메라니안", "믹스견", "치와와", "시츄", "비숑 프리제", "골든 리트리버", "진돗개", "웰시 코기", "프렌치 불독", "시바견", "닥스후트", "요크셔 테리어", "보더 콜리", "사모예드", "허스키", "말라뮤트", "기타(직접 입력)"], + "고양이": ["코리안 숏헤어", "브리티시 숏헤어", "아메리칸 숏헤어", "뱅갈", "메인쿤", "데본 렉스", "페르시안", "러시안 블루", "샴", "렉돌", "스코티시 폴드", "먼치킨", "노르웨이 숲", "믹스묘", "기타(직접 입력)"], + "햄스터": ["정글리안", "펄", "푸딩", "골든 햄스터", "로보로브스키", "기타(직접 입력)"], + "토끼": ["롭이어", "더치", "라이언 헤드", "드워프", "렉스", "기타(직접 입력)"], + "기니피그": ["잉글리쉬", "아비시니안", "페루비안", "실키", "기타(직접 입력)"], + "고슴도치": ["플라티나", "화이트 초코", "알비노", "핀토", "기타(직접 입력)"], + "기타(직접 입력)": ["기타(직접 입력)"] + }, + "파충류": { + "거북이": ["커먼 머스크 터틀", "레이저백", "육지거북", "붉은귀거북", "남생이", "기타(직접 입력)"], + "도마뱀": ["크레스티드 게코", "리키에너스 게코", "가고일 게코", "레오파드 게코", "비어디 드래곤", "블루텅 스킨크", "이구아나", "기타(직접 입력)"], + "뱀": ["볼 파이톤", "가터 스네이크", "호그노즈 스네이크", "콘 스네이크", "킹 스네이크", "밀크 스네이크", "기타(직접 입력)"], + "기타(직접 입력)": ["기타(직접 입력)"] + }, + "조류": { + "앵무새(소/중형)": ["사랑앵무(잉꼬)", "코카티엘(왕관앵무)", "모란앵무", "코뉴어", "퀘이커", "카카리키", "사자나미(빗금앵무)", "유리앵무", "기타(직접 입력)"], + "앵무새(대형)": ["뉴기니아", "회색앵무", "금강앵무(마카우)", "유황앵무(코카투)", "아마존앵무", "대본영", "기타(직접 입력)"], + "핀치/관상조": ["카나리아", "십자매", "문조", "금화조", "호금조", "백문조", "기타(직접 입력)"], + "비둘기/닭/메추리": ["애완용 비둘기", "관상닭(실키 등)", "메추리", "미니메추리", "오리/거위", "기타(직접 입력)"], + "기타(직접 입력)": ["직접 입력"] + }, + "양서류": { + "개구리": ["청개구리", "팩맨", "다트 프록", "화이트 트리 프록", "기타(직접 입력)"], + "도룡뇽": ["우파루파", "파이어 벨리 뉴트", "타이거 살라만더", "기타(직접 입력)"], + "기타(직접 입력)": ["기타(직접 입력)"] + }, + "어류": { + "열대어": ["구피", "베타", "테트라", "디스커스", "엔젤피쉬", "기타(직접 입력)"], + "금붕어/잉어": ["금붕어", "비단잉어", "기타(직접 입력)"], + "해수어": ["크라운피쉬(니모)", "블루탱", "기타(직접 입력)"], + "기타(직접 입력)": ["기타(직접 입력)"] + }, + "기타(직접 입력)": { + "기타(직접 입력)": ["기타(직접 입력)"] + } + }; + + // 1. Seed Diseases + for (const d of diseaseList) { + await PetDisease.findOrCreate({ where: { name: d } }); + } + + // 2. Seed Group -> Species -> Breeds + for (const [groupName, speciesMap] of Object.entries(breedsData)) { + const [group] = await PetGroup.findOrCreate({ where: { name: groupName } }); + + for (const [speciesName, breedList] of Object.entries(speciesMap)) { + const [species] = await PetSpecies.findOrCreate({ + where: { name: speciesName, groupId: group.id } + }); + + for (const breedName of breedList) { + await PetBreed.findOrCreate({ + where: { name: breedName, speciesId: species.id } + }); + } + } + } + + console.log('Master Data Seeded Successfully.'); + + } catch (error) { + console.error('Seeding Failed:', error); + } +}; + +module.exports = seedData; diff --git a/{ b/{ new file mode 100644 index 0000000..e69de29