rup-project/app/lib/screens/pet_form_screen.dart

1808 lines
66 KiB
Dart

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 '../services/firestore_service.dart';
import '../models/pet_model.dart';
class PetFormScreen extends StatefulWidget {
final Pet? petToEdit;
const PetFormScreen({super.key, this.petToEdit});
@override
State<PetFormScreen> createState() => _PetFormScreenState();
}
class _PetFormScreenState extends State<PetFormScreen> {
// 정확한 날짜를 몰라요 상태
bool _isDateUnknown = false;
final TextEditingController _monthController = TextEditingController();
final TextEditingController _yearController = TextEditingController();
final TextEditingController _dayController = TextEditingController();
final TextEditingController _registrationNumberController =
TextEditingController();
final FocusNode _yearFocus = FocusNode();
final FocusNode _monthFocus = FocusNode();
final FocusNode _dayFocus = FocusNode();
bool get _isFormValid {
if (_nameController.text.trim().isEmpty) return false;
if (_speciesController.text.trim().isEmpty) return false;
if (_breedController.text.trim().isEmpty) return false;
if (_selectedGender == null) return false;
if (!_isDateUnknown) {
if (_yearController.text.length != 4 ||
_monthController.text.length != 2 ||
_dayController.text.length != 2) {
return false;
}
}
return true;
}
@override
void initState() {
super.initState();
// 수정 모드일 경우 초기값 설정
if (widget.petToEdit != null) {
_initializeData(widget.petToEdit!);
}
// 폼 상태 변경 감지를 위한 리스너 등록
_nameController.addListener(_updateState);
_speciesController.addListener(_updateState);
_breedController.addListener(_updateState);
_yearController.addListener(_updateState);
_monthController.addListener(_updateState);
_dayController.addListener(_updateState);
_weightController.addListener(_updateState);
_registrationNumberController.addListener(_updateState); // 추가
}
void _initializeData(Pet pet) {
// 1. 기본 정보 설정
_nameController.text = pet.name;
_speciesController.text = pet.species;
_breedController.text = pet.breed;
_selectedGender = pet.gender;
_isNeutered = pet.isNeutered;
_isDateUnknown = pet.isDateUnknown;
// 성별 텍스트 설정
if (_selectedGender == '기타') {
_genderController.text = '기타';
} else {
_genderController.text = '$_selectedGender${_isNeutered ? '(중성화)' : ''}';
}
// 2. 날짜 설정
if (!_isDateUnknown && pet.birthDate != null) {
_yearController.text = pet.birthDate!.year.toString();
_monthController.text = pet.birthDate!.month.toString().padLeft(2, '0');
_dayController.text = pet.birthDate!.day.toString().padLeft(2, '0');
}
// 3. 등록번호 & 체중
if (pet.registrationNumber != null) {
_registrationNumberController.text = pet.registrationNumber!;
}
if (pet.weight != null) {
_weightController.text = pet.weight.toString();
}
// 4. 질환 목록 파싱
_parseAndSetDiseases(pet.diseases, (s, o, text) {
_selectedDiseases = s;
_otherDiseaseText = o;
_diseaseController.text = text;
});
_parseAndSetDiseases(pet.pastDiseases, (s, o, text) {
_selectedPastDiseases = s;
_otherPastDiseaseText = o;
_pastDiseaseController.text = text;
});
_parseAndSetDiseases(pet.healthConcerns, (s, o, text) {
_selectedHealthConcerns = s;
_otherHealthConcernText = o;
_healthConcernController.text = text;
});
}
void _parseAndSetDiseases(
List<String> source,
Function(List<String>, String, String) onSet,
) {
List<String> selected = [];
String otherText = '';
for (var item in source) {
if (item.startsWith('기타(') && item.endsWith(')')) {
selected.add('기타');
otherText = item.substring(3, item.length - 1);
} else {
selected.add(item);
}
}
// 화면 표시 텍스트 생성
List<String> displayList = selected.where((e) => e != '기타').toList();
if (selected.contains('기타') && otherText.isNotEmpty) {
displayList.add('기타($otherText)');
} else if (selected.contains('기타')) {
displayList.add('기타');
}
onSet(selected, otherText, displayList.join(', '));
}
void _updateState() {
setState(() {});
}
final TextEditingController _nameController = TextEditingController();
final TextEditingController _speciesController = TextEditingController();
final TextEditingController _breedController = TextEditingController();
final TextEditingController _genderController = TextEditingController();
final TextEditingController _weightController = TextEditingController();
// 선택된 종 정보 (품종 선택을 위해 필요)
String? _currentMajorCategory;
String? _currentMinorCategory;
String? _selectedGender;
bool _isNeutered = false;
List<String> _selectedDiseases = [];
String _otherDiseaseText = '';
final TextEditingController _diseaseController = TextEditingController();
List<String> _selectedPastDiseases = [];
File? _profileImage;
final ImagePicker _picker = ImagePicker();
void _pickImage() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 20.h),
Text(
'프로필 사진 설정',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 20.h),
ListTile(
leading: Icon(
Icons.camera_alt,
color: Colors.black,
size: 24.w,
),
title: Text(
'카메라로 촬영',
style: TextStyle(fontFamily: 'SCDream', fontSize: 16.sp),
),
onTap: () async {
Navigator.pop(context);
final XFile? image = await _picker.pickImage(
source: ImageSource.camera,
);
if (image != null)
setState(() => _profileImage = File(image.path));
},
),
ListTile(
leading: Icon(
Icons.photo_library,
color: Colors.black,
size: 24.w,
),
title: Text(
'갤러리에서 선택',
style: TextStyle(fontFamily: 'SCDream', fontSize: 16.sp),
),
onTap: () async {
Navigator.pop(context);
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
);
if (image != null)
setState(() => _profileImage = File(image.path));
},
),
ListTile(
leading: Icon(
Icons.delete_outline,
color: Colors.black,
size: 24.w,
),
title: Text(
'기본 이미지로 변경',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 16.sp,
color: Colors.black,
),
),
onTap: () {
Navigator.pop(context);
setState(() => _profileImage = null);
},
),
SizedBox(height: 20.h),
],
),
);
},
);
}
String _otherPastDiseaseText = '';
final TextEditingController _pastDiseaseController = TextEditingController();
List<String> _selectedHealthConcerns = [];
String _otherHealthConcernText = '';
final TextEditingController _healthConcernController =
TextEditingController();
@override
void dispose() {
_nameController.dispose();
_speciesController.dispose();
_breedController.dispose();
_genderController.dispose();
_yearController.dispose();
_monthController.dispose();
_dayController.dispose();
_diseaseController.dispose();
_pastDiseaseController.dispose();
_healthConcernController.dispose();
_yearFocus.dispose();
_monthFocus.dispose();
_dayFocus.dispose();
_registrationNumberController.dispose();
_weightController.dispose();
super.dispose();
}
bool _isLoading = false;
Future<void> _submitForm() async {
setState(() => _isLoading = true);
try {
final firestoreService = FirestoreService();
final userId = firestoreService.getCurrentUserId();
if (userId == null) {
throw Exception('로그인이 필요합니다.');
}
DateTime? birthDate;
if (!_isDateUnknown) {
birthDate = DateTime(
int.parse(_yearController.text),
int.parse(_monthController.text),
int.parse(_dayController.text),
);
}
List<String> finalDiseases = List.from(_selectedDiseases);
if (finalDiseases.contains('기타') && _otherDiseaseText.isNotEmpty) {
finalDiseases.remove('기타');
finalDiseases.add('기타($_otherDiseaseText)');
}
List<String> finalPastDiseases = List.from(_selectedPastDiseases);
if (finalPastDiseases.contains('기타') &&
_otherPastDiseaseText.isNotEmpty) {
finalPastDiseases.remove('기타');
finalPastDiseases.add('기타($_otherPastDiseaseText)');
}
List<String> finalHealthConcerns = List.from(_selectedHealthConcerns);
if (finalHealthConcerns.contains('기타') &&
_otherHealthConcernText.isNotEmpty) {
finalHealthConcerns.remove('기타');
finalHealthConcerns.add('기타($_otherHealthConcernText)');
}
// 수정 모드
if (widget.petToEdit != null) {
final updatedPet = Pet(
id: widget.petToEdit!.id,
ownerId: widget.petToEdit!.ownerId,
name: _nameController.text,
species: _speciesController.text,
breed: _breedController.text,
gender: _selectedGender!,
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,
diseases: finalDiseases,
pastDiseases: finalPastDiseases,
healthConcerns: finalHealthConcerns,
createdAt: widget.petToEdit!.createdAt,
);
await firestoreService.updatePet(updatedPet, _profileImage);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, 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),
),
);
Navigator.pop(context);
}
// 등록 모드
else {
final newPet = Pet(
id: firestoreService.generatePetId(),
ownerId: userId,
name: _nameController.text,
species: _speciesController.text,
breed: _breedController.text,
gender: _selectedGender!,
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,
diseases: finalDiseases,
pastDiseases: finalPastDiseases,
healthConcerns: finalHealthConcerns,
createdAt: DateTime.now(),
);
await firestoreService.registerPet(newPet, _profileImage);
if (!mounted) return;
Navigator.pushReplacementNamed(context, '/register_complete');
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('처리 실패: $e')));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _toggleDateUnknown() {
setState(() {
_isDateUnknown = !_isDateUnknown;
if (_isDateUnknown) {
_yearController.clear();
_monthController.clear();
_dayController.clear();
}
});
}
// --- UI Helpers & Modals (Copied and adapted from registration screen) ---
// 공통 선택 모달 (보유 질환, 과거 진단, 염려 건강) - Generic Selection Modal
void _showSelectionModal({
required String title,
required List<String> currentSelected,
required String currentOtherText,
required Function(List<String>, String) onComplete,
}) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) {
List<String> tempSelected = List.from(currentSelected);
final TextEditingController otherInputController =
TextEditingController(text: currentOtherText);
return StatefulBuilder(
builder: (BuildContext context, StateSetter setModalState) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Container(
height: 0.85.sh,
margin: EdgeInsets.only(top: 50.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
),
child: Column(
children: [
SizedBox(height: 20.h),
Text(
title,
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
SizedBox(height: 10.h),
Divider(color: const Color(0xFFEEEEEE), thickness: 1.h),
Expanded(
child: ListView.builder(
itemCount: PetData.diseaseList.length,
padding: const EdgeInsets.symmetric(vertical: 10),
itemBuilder: (context, index) {
final disease = PetData.diseaseList[index];
final isSelected = tempSelected.contains(disease);
return Column(
children: [
InkWell(
onTap: () {
setModalState(() {
if (isSelected)
tempSelected.remove(disease);
else
tempSelected.add(disease);
});
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
child: Row(
children: [
Icon(
Icons.check,
size: 20,
color: isSelected
? AppColors.highlight
: Colors.grey[300],
),
const SizedBox(width: 12),
Expanded(
child: Text(
disease,
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 16,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
color: isSelected
? AppColors.highlight
: Colors.black,
),
),
),
],
),
),
),
if (isSelected && disease == "기타")
Padding(
padding: EdgeInsets.fromLTRB(
52.w,
0,
20.w,
10.h,
),
child: TextField(
controller: otherInputController,
autofocus: true,
decoration: InputDecoration(
hintText: '직접 입력해 주세요',
isDense: true,
contentPadding: EdgeInsets.symmetric(
vertical: 10.h,
horizontal: 10.w,
),
border: const OutlineInputBorder(),
),
),
),
],
);
},
),
),
Padding(
padding: EdgeInsets.fromLTRB(
20.w,
20.h,
20.w,
20.h + bottomInset,
),
child: Row(
children: [
InkWell(
onTap: () => setModalState(() {
tempSelected.clear();
otherInputController.clear();
}),
child: Container(
height: 52.h,
padding: EdgeInsets.symmetric(horizontal: 20.w),
decoration: BoxDecoration(
color: const Color(0xFF333333),
borderRadius: BorderRadius.circular(12.r),
),
child: Row(
children: [
Icon(
Icons.refresh,
color: Colors.white,
size: 20.w,
),
SizedBox(width: 4.w),
const Text(
'초기화',
style: TextStyle(
fontFamily: 'SCDream',
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
SizedBox(width: 12.w),
Expanded(
child: InkWell(
onTap: () {
onComplete(
tempSelected,
otherInputController.text,
);
Navigator.pop(context);
},
child: Container(
height: 52.h,
decoration: BoxDecoration(
color: AppColors.highlight,
borderRadius: BorderRadius.circular(12.r),
),
child: Center(
child: Text(
'선택 완료',
style: TextStyle(
fontFamily: 'SCDream',
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
],
),
),
],
),
);
},
);
},
);
}
void _showSpeciesSelectionModal() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) {
String? selectedMajor; // 모달 내부 임시 상태 (대분류)
bool showInput = false; // 직접 입력 창 표시 여부
final TextEditingController speciesInputController =
TextEditingController();
return StatefulBuilder(
builder: (BuildContext context, StateSetter setModalState) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Container(
height: 0.6.sh,
margin: EdgeInsets.only(top: 50.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
),
child: Column(
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 12.h,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
(selectedMajor != null || showInput)
? GestureDetector(
onTap: () => setModalState(() {
if (showInput)
showInput = false;
else
selectedMajor = null;
}),
child: Icon(
Icons.arrow_back_ios,
size: 20.w,
color: Colors.black,
),
)
: SizedBox(width: 20.w),
Text(
showInput
? '직접 입력'
: (selectedMajor == null ? '대분류' : '중분류'),
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
GestureDetector(
onTap: () => Navigator.pop(context),
child: Icon(
Icons.close,
size: 24.w,
color: Colors.black,
),
),
],
),
),
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,
),
),
SizedBox(height: 20.h),
TextField(
controller: speciesInputController,
autofocus: true,
decoration: const InputDecoration(
hintText: '예: 미어캣',
border: OutlineInputBorder(),
),
),
Spacer(),
SizedBox(
width: double.infinity,
height: 52.h,
child: ElevatedButton(
onPressed: () {
if (speciesInputController
.text
.isNotEmpty) {
setState(() {
_speciesController.text =
speciesInputController.text;
_currentMajorCategory = null;
_currentMinorCategory = null;
_breedController.clear();
});
Navigator.pop(context);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.highlight,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
12.r,
),
),
),
child: Text(
'완료',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
)
: 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);
}
},
);
}
},
),
),
],
),
);
},
);
},
);
}
void _showBreedSelectionModal() {
if (_speciesController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('반려동물 종을 먼저 선택해주세요.'),
duration: Duration(seconds: 1),
),
);
return;
}
if (_currentMajorCategory == null || _currentMinorCategory == null) {
_showBreedDirectInputModal();
return;
}
final List<String> originalList = PetData
.breedsData[_currentMajorCategory]![_currentMinorCategory]!
.where((e) => e != '기타(직접 입력)')
.toList();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
String searchText = '';
List<String> filteredList = List.from(originalList);
TextEditingController searchController = TextEditingController();
bool showInput = false;
final TextEditingController manualInputController =
TextEditingController();
return StatefulBuilder(
builder: (BuildContext context, StateSetter setModalState) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Container(
height: 0.85.sh,
margin: EdgeInsets.only(top: 50.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
),
child: Column(
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 12.h,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
showInput
? GestureDetector(
onTap: () =>
setModalState(() => showInput = false),
child: Icon(Icons.arrow_back_ios),
)
: SizedBox(width: 20),
Text(
showInput ? '직접 입력' : '품종 선택',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
GestureDetector(
onTap: () => Navigator.pop(context),
child: Icon(Icons.close),
),
],
),
),
Divider(),
Expanded(
child: showInput
? Padding(
padding: EdgeInsets.all(20.w),
child: Column(
children: [
TextField(
controller: manualInputController,
autofocus: true,
decoration: InputDecoration(
hintText: '예: 믹스',
),
),
Spacer(),
ElevatedButton(
onPressed: () {
if (manualInputController.text.isNotEmpty) {
setState(
() => _breedController.text =
manualInputController.text,
);
Navigator.pop(context);
}
},
child: Text('완료'),
),
],
),
)
: Column(
children: [
Padding(
padding: EdgeInsets.all(10),
child: TextField(
controller: searchController,
onChanged: (v) {
setModalState(() {
searchText = v;
filteredList = v.isEmpty
? List.from(originalList)
: originalList
.where((b) => b.contains(v))
.toList();
});
},
decoration: InputDecoration(
hintText: '검색',
prefixIcon: Icon(Icons.search),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
),
Expanded(
child: ListView.builder(
itemCount: filteredList.length + 1,
itemBuilder: (ctx, idx) {
if (idx == filteredList.length)
return ListTile(
title: Text('기타(직접 입력)'),
onTap: () => setModalState(
() => showInput = true,
),
);
return ListTile(
title: Text(filteredList[idx]),
onTap: () {
setState(
() => _breedController.text =
filteredList[idx],
);
Navigator.pop(context);
},
);
},
),
),
],
),
),
],
),
);
},
);
},
);
}
void _showBreedDirectInputModal() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
final controller = TextEditingController();
return Container(
height: 0.5.sh,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
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('완료'),
),
],
),
);
},
);
}
void _showGenderSelectionModal() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) {
String? tempGender = _selectedGender;
bool tempNeutered = _isNeutered;
return StatefulBuilder(
builder: (context, setModalState) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(width: 24),
Text(
'성별 선택',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
GestureDetector(
onTap: () => Navigator.pop(context),
child: Icon(Icons.close),
),
],
),
),
Divider(),
Padding(
padding: EdgeInsets.all(20),
child: Row(
children: [
Expanded(
child: _buildGenderCard(
'남아',
Icons.male,
tempGender == '남아',
(v) => setModalState(() => tempGender = v),
),
),
SizedBox(width: 12),
Expanded(
child: _buildGenderCard(
'여아',
Icons.female,
tempGender == '여아',
(v) => setModalState(() => tempGender = v),
),
),
SizedBox(width: 12),
Expanded(
child: _buildGenderCard(
'기타',
Icons.question_mark,
tempGender == '기타',
(v) => setModalState(() => tempGender = v),
),
),
],
),
),
GestureDetector(
onTap: () =>
setModalState(() => tempNeutered = !tempNeutered),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
tempNeutered
? Icons.check_box
: Icons.check_box_outline_blank,
color: tempNeutered
? AppColors.highlight
: Colors.grey,
),
SizedBox(width: 8),
Text('중성화를 했어요'),
],
),
),
Padding(
padding: EdgeInsets.all(20),
child: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: () {
setState(() {
_selectedGender = tempGender;
_isNeutered = tempNeutered;
_genderController.text = _selectedGender == '기타'
? '기타'
: '$_selectedGender${_isNeutered ? "(중성화)" : ""}';
});
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.highlight,
),
child: Text(
'선택 완료',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
);
},
);
},
);
}
Widget _buildGenderCard(
String gender,
IconData icon,
bool isSelected,
Function(String) onTap,
) {
return GestureDetector(
onTap: () => onTap(gender),
child: Container(
height: 100,
decoration: BoxDecoration(
color: isSelected
? AppColors.highlight.withOpacity(0.1)
: Colors.white,
border: Border.all(
color: isSelected ? AppColors.highlight : Color(0xFFEEEEEE),
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 40,
color: isSelected ? AppColors.highlight : Colors.grey,
),
SizedBox(height: 12),
Text(
gender,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isSelected ? AppColors.highlight : Colors.grey,
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final bool isEditMode = widget.petToEdit != null;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text(
isEditMode ? '반려동물 정보 수정' : '반려동물 등록',
style: TextStyle(
color: Color(0xFF1f1f1f),
fontFamily: 'SCDream',
fontWeight: FontWeight.w500,
fontSize: 15.sp,
),
),
centerTitle: true,
backgroundColor: Colors.white,
scrolledUnderElevation: 0,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back_ios, color: Colors.black, size: 16.w),
onPressed: () => Navigator.pop(context),
),
),
body: SingleChildScrollView(
padding: EdgeInsets.all(20.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. 프로필 이미지 영역
Center(
child: GestureDetector(
onTap: _pickImage,
child: Stack(
children: [
Container(
width: 100.w,
height: 100.w,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFEEEEEE)),
image: _profileImage != null
? DecorationImage(
image: FileImage(_profileImage!),
fit: BoxFit.cover,
)
: (isEditMode &&
widget.petToEdit!.profileImageUrl != null
? DecorationImage(
image: NetworkImage(
widget.petToEdit!.profileImageUrl!,
),
fit: BoxFit.cover,
)
: null),
),
child:
(_profileImage == null &&
(!isEditMode ||
widget.petToEdit!.profileImageUrl == null))
? Center(
child: SvgPicture.asset(
'assets/icons/profile_icon.svg',
width: 40.w,
colorFilter: ColorFilter.mode(
Colors.grey[400]!,
BlendMode.srcIn,
),
),
)
: null,
),
Positioned(
bottom: 0,
right: 0,
child: Container(
padding: EdgeInsets.all(6.w),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFEEEEEE)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4.r,
offset: Offset(0, 2.h),
),
],
),
child: Icon(
Icons.camera_alt,
size: 16.w,
color: Colors.black87,
),
),
),
],
),
),
),
SizedBox(height: 30.h),
// 2. 반려동물 이름 입력
_buildLabel('반려동물 이름 입력', isRequired: true),
_buildTextField(
controller: _nameController,
hint: '이름 입력 (2~10글자/한글/영문/숫자)',
inputFormatters: [
LengthLimitingTextInputFormatter(10), // 최대 10글자 제한
],
),
SizedBox(height: 20.h),
// 3. 선택 박스들 (종, 품종, 성별)
_buildSearchField(
'반려동물 종 선택',
controller: _speciesController,
readOnly: true,
onTap: _showSpeciesSelectionModal,
isRequired: true,
),
SizedBox(height: 20.h),
_buildSearchField(
'반려동물 품종 선택',
controller: _breedController,
readOnly: true,
onTap: _showBreedSelectionModal,
isRequired: true,
),
SizedBox(height: 20.h),
_buildSearchField(
'반려동물 성별',
controller: _genderController,
readOnly: true,
onTap: _showGenderSelectionModal,
isRequired: true,
),
SizedBox(height: 20.h),
// 4. 생년월일
_buildLabel('반려동물 생년월일', isRequired: true),
Row(
children: [
Expanded(
child: _buildTextField(
controller: _yearController,
focusNode: _yearFocus,
hint: 'YYYY',
textAlign: TextAlign.center,
hintColor: _isDateUnknown
? const Color(0xFFC8C8C8)
: const Color(0xFF7D7C7C),
enabled: !_isDateUnknown,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
onChanged: (value) {
if (value.length == 4) {
FocusScope.of(context).requestFocus(_monthFocus);
}
},
),
),
SizedBox(width: 12.w),
Expanded(
child: _buildTextField(
controller: _monthController,
focusNode: _monthFocus,
hint: 'MM',
textAlign: TextAlign.center,
hintColor: _isDateUnknown
? const Color(0xFFC8C8C8)
: const Color(0xFF7D7C7C),
enabled: !_isDateUnknown,
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: 12.w),
Expanded(
child: _buildTextField(
controller: _dayController,
focusNode: _dayFocus,
hint: 'DD',
textAlign: TextAlign.center,
hintColor: _isDateUnknown
? const Color(0xFFC8C8C8)
: const Color(0xFF7D7C7C),
enabled: !_isDateUnknown,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(2),
DayInputFormatter(
monthController: _monthController,
yearController: _yearController,
),
],
),
),
],
),
const SizedBox(height: 12),
GestureDetector(
onTap: _toggleDateUnknown,
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 14.h),
decoration: BoxDecoration(
color: _isDateUnknown
? AppColors.subHighlight
: AppColors.inactive,
borderRadius: BorderRadius.circular(30.r),
),
child: Center(
child: Text(
'정확한 날짜를 몰라요',
style: TextStyle(
fontFamily: 'SCDream',
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 14.sp,
),
),
),
),
),
const SizedBox(height: 24),
// 5. 동물 등록 번호
_buildTextField(
controller: _registrationNumberController,
hint: '숫자만 입력',
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(15),
],
),
const SizedBox(height: 24),
// 체중 입력
_buildLabel('몸무게 (kg)', isRequired: false),
_buildTextField(
controller: _weightController,
hint: '예: 4.5',
suffix: 'kg',
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
),
const SizedBox(height: 24),
// 6. 질환 정보
_buildSearchField(
'보유 질환',
controller: _diseaseController,
readOnly: true,
onTap: () => _showSelectionModal(
title: '보유 질환 선택',
currentSelected: _selectedDiseases,
currentOtherText: _otherDiseaseText,
onComplete: (selected, otherText) {
setState(() {
_selectedDiseases = selected;
_otherDiseaseText = otherText;
_parseAndSetDiseases(
selected,
(s, o, t) => _diseaseController.text = t,
); // Reusing logic
// Helper logic copy
List<String> displayList = selected
.where((e) => e != '기타')
.toList();
if (selected.contains('기타') && otherText.isNotEmpty) {
displayList.add('기타($otherText)');
} else if (selected.contains('기타')) {
displayList.add('기타');
}
_diseaseController.text = displayList.join(', ');
});
},
),
),
SizedBox(height: 20.h),
_buildSearchField(
'과거 진단받은 질병',
controller: _pastDiseaseController,
readOnly: true,
onTap: () => _showSelectionModal(
title: '과거 진단받은 질병 선택',
currentSelected: _selectedPastDiseases,
currentOtherText: _otherPastDiseaseText,
onComplete: (selected, otherText) {
setState(() {
_selectedPastDiseases = selected;
_otherPastDiseaseText = otherText;
List<String> displayList = selected
.where((e) => e != '기타')
.toList();
if (selected.contains('기타') && otherText.isNotEmpty) {
displayList.add('기타($otherText)');
} else if (selected.contains('기타')) {
displayList.add('기타');
}
_pastDiseaseController.text = displayList.join(', ');
});
},
),
),
SizedBox(height: 20.h),
_buildSearchField(
'염려되는 건강 문제',
controller: _healthConcernController,
readOnly: true,
onTap: () => _showSelectionModal(
title: '염려되는 건강 문제 선택',
currentSelected: _selectedHealthConcerns,
currentOtherText: _otherHealthConcernText,
onComplete: (selected, otherText) {
setState(() {
_selectedHealthConcerns = selected;
_otherHealthConcernText = otherText;
List<String> displayList = selected
.where((e) => e != '기타')
.toList();
if (selected.contains('기타') && otherText.isNotEmpty) {
displayList.add('기타($otherText)');
} else if (selected.contains('기타')) {
displayList.add('기타');
}
_healthConcernController.text = displayList.join(', ');
});
},
),
),
SizedBox(height: 40.h),
// 7. 등록 버튼
SizedBox(
width: double.infinity,
height: 52.h,
child: ElevatedButton(
onPressed: (_isFormValid && !_isLoading) ? _submitForm : null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.highlight,
disabledBackgroundColor: AppColors.inactive,
disabledForegroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30.r),
),
),
child: _isLoading
? SizedBox(
width: 24.w,
height: 24.w,
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
isEditMode ? '수정 완료' : '반려동물 등록',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
SizedBox(height: 20.h),
],
),
),
);
}
// Helper Widget: 라벨 (필수 표시 포함)
Widget _buildLabel(String text, {bool isRequired = false}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isRequired)
Padding(
padding: EdgeInsets.only(right: 4.w),
child: Container(
width: 4.w,
height: 4.w,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
Text(
text,
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: Colors.black,
),
),
],
);
}
// Helper Widget: 텍스트 입력 필드
Widget _buildTextField({
required TextEditingController? controller, // made required for sanity
required String hint, // modified signature slightly for ease
TextAlign textAlign = TextAlign.start,
Color? hintColor,
bool enabled = true,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
FocusNode? focusNode,
ValueChanged<String>? onChanged,
String? suffix,
}) {
return TextField(
controller: controller,
focusNode: focusNode,
onChanged: onChanged,
enabled: enabled,
textAlign: textAlign,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 14.sp,
color: AppColors.text,
),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(
fontFamily: 'SCDream',
fontSize: 14.sp,
color: hintColor ?? Colors.grey,
),
suffixText: suffix,
suffixStyle: TextStyle(
fontFamily: 'SCDream',
fontSize: 14.sp,
fontWeight: FontWeight.bold,
color: Colors.black,
),
enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Color(0xFFDDDDDD)),
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: AppColors.highlight),
),
contentPadding: EdgeInsets.symmetric(vertical: 8.h),
isDense: true,
),
);
}
Widget _buildSearchField(
String label, {
TextEditingController? controller,
VoidCallback? onTap,
bool readOnly = false,
bool isRequired = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLabel(label, isRequired: isRequired),
TextField(
controller: controller,
onTap: onTap,
readOnly: readOnly,
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 14.sp,
overflow: TextOverflow.ellipsis,
),
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 8.h),
enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Color(0xFFDDDDDD)),
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: AppColors.highlight),
),
suffixIcon: const Icon(Icons.search, color: Colors.black87),
suffixIconConstraints: BoxConstraints(
minWidth: 24.w,
minHeight: 24.w,
),
),
),
],
);
}
}
// --- 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];
}
}