홈화면 동물 프로필 카드 추가 / 수정 페이지 추가/ 수정, 추가에 따른 db 업데이트

This commit is contained in:
youngbeom 2026-01-25 15:54:14 +09:00
parent 8dc2524ba6
commit 721b748703
11 changed files with 3508 additions and 145 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -12,6 +12,7 @@ class Pet {
final bool isDateUnknown;
final String? registrationNumber;
final String? profileImageUrl;
final double? weight; // (kg)
final List<String> diseases;
final List<String> pastDiseases;
final List<String> healthConcerns;
@ -29,6 +30,7 @@ class Pet {
required this.isDateUnknown,
this.registrationNumber,
this.profileImageUrl,
this.weight,
required this.diseases,
required this.pastDiseases,
required this.healthConcerns,
@ -48,6 +50,7 @@ class Pet {
'isDateUnknown': isDateUnknown,
'registrationNumber': registrationNumber,
'profileImageUrl': profileImageUrl,
'weight': weight,
'diseases': diseases,
'pastDiseases': pastDiseases,
'healthConcerns': healthConcerns,
@ -70,6 +73,7 @@ class Pet {
isDateUnknown: map['isDateUnknown'] ?? false,
registrationNumber: map['registrationNumber'],
profileImageUrl: map['profileImageUrl'],
weight: map['weight']?.toDouble(),
diseases: List<String>.from(map['diseases'] ?? []),
pastDiseases: List<String>.from(map['pastDiseases'] ?? []),
healthConcerns: List<String>.from(map['healthConcerns'] ?? []),

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'pet_registration_screen.dart';
import 'pet_form_screen.dart';
import '../services/firestore_service.dart';
import '../models/pet_model.dart';
import '../theme/app_colors.dart';
import '../widgets/home/pet_profile_card.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@ -24,124 +25,6 @@ class _HomeScreenState extends State<HomeScreen> {
_userId = _firestoreService.getCurrentUserId();
}
//
void _selectPet(Pet pet) {
setState(() {
_selectedPet = pet;
});
Navigator.pop(context); //
}
//
void _showPetSelectionModal(List<Pet> pets) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
),
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),
//
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: pets.length,
itemBuilder: (context, index) {
final pet = pets[index];
final isSelected = pet.id == _selectedPet?.id;
return ListTile(
leading: CircleAvatar(
radius: 24.r,
backgroundColor: Colors.grey[200],
backgroundImage: pet.profileImageUrl != null
? NetworkImage(pet.profileImageUrl!)
: null,
child: pet.profileImageUrl == null
? SvgPicture.asset(
'assets/icons/profile_icon.svg',
width: 24.w,
colorFilter: ColorFilter.mode(
Colors.grey[400]!,
BlendMode.srcIn,
),
)
: null,
),
title: Text(
pet.name,
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 16.sp,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
color: isSelected
? AppColors.highlight
: Colors.black,
),
),
trailing: isSelected
? const Icon(Icons.check, color: AppColors.highlight)
: null,
onTap: () => _selectPet(pet),
);
},
),
),
Divider(thickness: 1, color: Colors.grey[200]),
//
ListTile(
leading: Container(
width: 48.r,
height: 48.r,
decoration: BoxDecoration(
color: Colors.grey[100],
shape: BoxShape.circle,
),
child: const Icon(Icons.add, color: Colors.black54),
),
title: Text(
'반려동물 추가하기',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PetRegistrationScreen(),
),
);
},
),
SizedBox(height: 30.h),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
if (_userId == null) {
@ -182,8 +65,7 @@ class _HomeScreenState extends State<HomeScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const PetRegistrationScreen(),
builder: (context) => const PetFormScreen(),
),
);
},
@ -233,19 +115,16 @@ class _HomeScreenState extends State<HomeScreen> {
//
//
if (_selectedPet == null ||
!pets.any((p) => p.id == _selectedPet!.id)) {
// We shouldn't update state directly in build, but for initialization it's tricky.
// Using the first pet as default display.
// Better approach: use a local variable for display, update state in callbacks.
_selectedPet = pets.first;
}
//
Pet displayPet;
// To ensure _selectedPet is valid (e.g. after deletion), find it in the new list
final displayPet = pets.firstWhere(
(p) => p.id == _selectedPet?.id,
orElse: () => pets.first,
);
// (State )
if (_selectedPet != null &&
pets.any((p) => p.id == _selectedPet!.id)) {
displayPet = pets.firstWhere((p) => p.id == _selectedPet!.id);
} else {
displayPet = pets.first;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -255,8 +134,109 @@ class _HomeScreenState extends State<HomeScreen> {
horizontal: 20.w,
vertical: 20.h,
),
child: GestureDetector(
onTap: () => _showPetSelectionModal(pets),
child: PopupMenuButton<dynamic>(
offset: Offset(0, 50.h), //
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
color: Colors.white,
surfaceTintColor: Colors.white,
onSelected: (value) {
if (value is Pet) {
setState(() {
_selectedPet = value;
});
} else if (value == 'add_pet') {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PetFormScreen(),
),
);
}
},
itemBuilder: (context) {
return [
...pets.map(
(pet) => PopupMenuItem<Pet>(
value: pet,
child: Row(
children: [
CircleAvatar(
radius: 16.r,
backgroundColor: Colors.grey[200],
backgroundImage: pet.profileImageUrl != null
? NetworkImage(pet.profileImageUrl!)
: null,
child: pet.profileImageUrl == null
? SvgPicture.asset(
'assets/icons/profile_icon.svg',
width: 16.w,
colorFilter: ColorFilter.mode(
Colors.grey[400]!,
BlendMode.srcIn,
),
)
: null,
),
SizedBox(width: 10.w),
Text(
pet.name,
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 14.sp,
fontWeight: pet.id == displayPet.id
? FontWeight.bold
: FontWeight.normal,
color: pet.id == displayPet.id
? AppColors.highlight
: Colors.black,
),
),
if (pet.id == displayPet.id) ...[
const Spacer(),
const Icon(
Icons.check,
color: AppColors.highlight,
size: 16,
),
],
],
),
),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: 'add_pet',
child: Row(
children: [
Container(
padding: EdgeInsets.all(4.w),
decoration: BoxDecoration(
color: Colors.grey[100],
shape: BoxShape.circle,
),
child: Icon(
Icons.add,
size: 16.w,
color: Colors.black54,
),
),
SizedBox(width: 10.w),
Text(
'반려동물 추가하기',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 14.sp,
fontWeight: FontWeight.w500,
),
),
],
),
),
];
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -301,15 +281,8 @@ class _HomeScreenState extends State<HomeScreen> {
),
),
Expanded(
child: Center(
child: Text(
'안녕, ${displayPet.name}!',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 24.sp,
fontWeight: FontWeight.bold,
),
),
child: SingleChildScrollView(
child: Column(children: [PetProfileCard(pet: displayPet)]),
),
),
],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -62,6 +62,7 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
_yearController.addListener(_updateState);
_monthController.addListener(_updateState);
_dayController.addListener(_updateState);
_weightController.addListener(_updateState);
}
void _updateState() {
@ -79,6 +80,8 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
TextEditingController(); //
final TextEditingController _genderController =
TextEditingController(); //
final TextEditingController _weightController =
TextEditingController(); //
// (Removed: Use PetData.breedsData)
@ -217,6 +220,7 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
_monthFocus.dispose();
_dayFocus.dispose();
_registrationNumberController.dispose();
_weightController.dispose();
super.dispose();
}
@ -281,6 +285,12 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
registrationNumber: _registrationNumberController.text.isNotEmpty
? _registrationNumberController.text
: null,
weight:
_weightController
.text
.isNotEmpty //
? double.tryParse(_weightController.text)
: null,
diseases: finalDiseases,
pastDiseases: finalPastDiseases,
healthConcerns: finalHealthConcerns,
@ -1616,6 +1626,21 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
),
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(
'보유 질환',
@ -1784,6 +1809,7 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
List<TextInputFormatter>? inputFormatters,
FocusNode? focusNode,
ValueChanged<String>? onChanged,
String? suffix,
}) {
return TextField(
controller: controller,
@ -1805,6 +1831,13 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
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)),
),

View File

@ -58,6 +58,7 @@ class FirestoreService {
isDateUnknown: pet.isDateUnknown,
registrationNumber: pet.registrationNumber,
profileImageUrl: imageUrl, // URL
weight: pet.weight, //
diseases: pet.diseases,
pastDiseases: pet.pastDiseases,
healthConcerns: pet.healthConcerns,
@ -74,6 +75,57 @@ class FirestoreService {
}
}
//
Future<void> updatePet(Pet pet, File? newImageFile) async {
try {
String? imageUrl = pet.profileImageUrl;
// 1. ()
if (newImageFile != null) {
// ,
// URL
final String fileName =
'${pet.id}_${DateTime.now().millisecondsSinceEpoch}.jpg';
final Reference storageRef = _storage
.ref()
.child('pet_images')
.child(fileName);
LogManager().addLog('[Storage] Uploading new image...');
await storageRef.putFile(newImageFile);
imageUrl = await storageRef.getDownloadURL();
}
// 2. Pet
Pet updatedPet = Pet(
id: pet.id, // ID
ownerId: pet.ownerId, // Owner ID
name: pet.name,
species: pet.species,
breed: pet.breed,
gender: pet.gender,
isNeutered: pet.isNeutered,
birthDate: pet.birthDate,
isDateUnknown: pet.isDateUnknown,
registrationNumber: pet.registrationNumber,
profileImageUrl: imageUrl,
weight: pet.weight,
diseases: pet.diseases,
pastDiseases: pet.pastDiseases,
healthConcerns: pet.healthConcerns,
createdAt: pet.createdAt, //
);
// 3. Firestore
await _db.collection('pets').doc(pet.id).set(updatedPet.toMap());
LogManager().addLog('[Firestore] Pet updated successfully: ${pet.id}');
} catch (e) {
LogManager().addLog('[Firestore] Error updating pet: $e');
throw Exception('반려동물 수정 실패: $e');
}
}
// ID
String? getCurrentUserId() {
return _auth.currentUser?.uid;

View File

@ -1,4 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; // For WidgetsBinding
import 'package:flutter/scheduler.dart'; // For SchedulerPhase
class LogManager {
static final LogManager _instance = LogManager._internal();
@ -12,7 +14,15 @@ class LogManager {
final timestamp = DateTime.now().toString().split(' ')[1].split('.')[0];
final logMessage = "[$timestamp] $message";
//
if (WidgetsBinding.instance.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
WidgetsBinding.instance.addPostFrameCallback((_) {
logs.value = [logMessage, ...logs.value];
});
} else {
logs.value = [logMessage, ...logs.value];
}
} catch (e) {
print('LogManager Error: $e');
}

View File

@ -0,0 +1,400 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:intl/intl.dart';
import '../../models/pet_model.dart';
import '../../screens/pet_form_screen.dart';
class PetProfileCard extends StatelessWidget {
final Pet pet;
const PetProfileCard({super.key, required this.pet});
// ( & - )
String _calculateAge(DateTime? birthDate) {
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세';
}
// ( - )
// ( )
String _calculateHumanAge(DateTime? birthDate, String species) {
if (birthDate == null) return '??세';
final now = DateTime.now();
int ageYears = now.year - birthDate.year;
//
if (now.month < birthDate.month ||
(now.month == birthDate.month && now.day < birthDate.day)) {
ageYears--;
}
// (/ )
int ageMonths =
(now.year - birthDate.year) * 12 + now.month - birthDate.month;
if (now.day < birthDate.day) {
ageMonths--;
}
int humanAge = 0;
switch (species) {
case '강아지':
// : 1=15, 2=24, 3+=5
if (ageYears < 1)
humanAge = (ageMonths * 1.25).round(); // 15/12
else if (ageYears == 1)
humanAge = 15;
else if (ageYears == 2)
humanAge = 24;
else
humanAge = 24 + (ageYears - 2) * 5;
break;
case '고양이':
// : 1=15, 2=24, 3+=4
if (ageYears < 1)
humanAge = (ageMonths * 1.25).round();
else if (ageYears == 1)
humanAge = 15;
else if (ageYears == 2)
humanAge = 24;
else
humanAge = 24 + (ageYears - 2) * 4;
break;
case '햄스터':
// : 1= 5, 1=58, 2=70,
// : 5 , 1
// 1~: month * 5
humanAge = ageMonths * 5;
// 1(12) = 60 . 2(24) = 120 ( ).
// 2~3
// 1=58, 2=70, 3=100
if (ageMonths >= 12 && ageMonths < 24) {
// 1~2 : 58 + ((month-12) * 1) -> 1 70
// 58 + (ageMonths - 12); // 12=58, 23=69
humanAge = 58 + (ageMonths - 12);
} else if (ageMonths >= 24) {
// 2 : 70 + (month-24)*2.5 (3 100)
humanAge = 70 + ((ageMonths - 24) * 2.5).round();
}
break;
case '토끼':
// : 6=16, 1=21, 2=28, +6
if (ageMonths < 6)
humanAge = (ageMonths * 2.6).round(); // 16/6
else if (ageMonths < 12)
humanAge = 16 + (ageMonths - 6); // 16~21
else if (ageYears == 1)
humanAge = 21;
else if (ageYears == 2)
humanAge = 28;
else
humanAge = 28 + (ageYears - 2) * 6;
break;
default:
// (, , ): fallback 1:1
// ,
// ( : )
if (ageYears < 1)
humanAge = (ageMonths * 1.25).round();
else if (ageYears == 1)
humanAge = 15;
else if (ageYears == 2)
humanAge = 24;
else
humanAge = 24 + (ageYears - 2) * 5;
break;
}
return '$humanAge세';
}
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.r),
image: const DecorationImage(
image: AssetImage('assets/img/profile_card_background.png'),
fit: BoxFit.cover,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// :
Container(
width: 120.w,
// height (stretch )
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
color: Colors.grey[200],
image: pet.profileImageUrl != null
? DecorationImage(
image: NetworkImage(pet.profileImageUrl!),
fit: BoxFit.cover,
)
: null,
),
child: pet.profileImageUrl == null
? Center(
child: SvgPicture.asset(
'assets/icons/profile_icon.svg',
width: 40.w,
colorFilter: ColorFilter.mode(
Colors.grey[400]!,
BlendMode.srcIn,
),
),
)
: null,
),
SizedBox(width: 10.w),
// :
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// &
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
Flexible(
child: Text(
pet.name,
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 22.sp,
fontWeight: FontWeight.bold,
color: Colors.black,
),
overflow: TextOverflow.ellipsis,
),
),
if (pet.gender == '남아' || pet.gender == '여아') ...[
SizedBox(width: 6.w),
Icon(
pet.gender == '남아' ? Icons.male : Icons.female,
color: pet.gender == '남아'
? Colors.blue
: Colors.pinkAccent,
size: 20.sp,
),
],
],
),
),
// ( )
// ( )
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
PetFormScreen(petToEdit: pet),
),
);
},
child: Padding(
padding: EdgeInsets.all(8.w),
child: Icon(
Icons.arrow_forward_ios,
size: 16.sp,
color: Colors.grey[400],
),
),
),
),
],
),
SizedBox(height: 2.h),
Text(
pet.breed,
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 14.sp,
color: Colors.grey[600],
),
),
SizedBox(height: 8.h), // (12 -> 8)
// (, )
Row(
children: [
_buildInfoBox(
'생일',
pet.birthDate != null
? DateFormat('yy.MM.dd').format(pet.birthDate!)
: '??.??.??',
),
SizedBox(width: 8.w),
_buildInfoBox(
'체중',
pet.weight != null ? '${pet.weight}kg' : '--kg',
),
],
),
SizedBox(height: 8.h), // (12 -> 8)
//
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
children: [
Text(
'나이 ',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 14.sp,
color: Colors.grey[600],
),
),
Text(
_calculateAge(pet.birthDate),
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 15.sp,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w),
child: Text(
'/',
style: TextStyle(color: Colors.grey[300]),
),
),
Text(
'사람나이 ',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 14.sp,
color: Colors.grey[600],
),
),
Text(
'사람 나이 환산 ${_calculateHumanAge(pet.birthDate, pet.species)}',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 12.sp,
color: Colors.white,
),
),
],
),
),
SizedBox(height: 6.h),
//
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'등록번호 ',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 14.sp,
color: Colors.grey[600],
height: 1.2, //
),
),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
pet.registrationNumber?.isNotEmpty == true
? pet.registrationNumber!
: '--',
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 15.sp,
fontWeight: FontWeight.bold,
color: Colors.black,
height: 1.2,
),
maxLines: 1,
),
),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildInfoBox(String label, String value) {
return Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: EdgeInsets.all(8.w),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.4), //
border: Border.all(color: const Color(0xFFEEEEEE), width: 1),
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 12.sp,
color: Colors.grey[600], //
),
),
SizedBox(height: 4.h),
Text(
value,
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 15.sp,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
],
),
),
),
),
);
}
}

View File

@ -520,6 +520,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.2"
intl:
dependency: "direct dev"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.19.0"
js:
dependency: transitive
description:

View File

@ -50,6 +50,7 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
intl: ^0.19.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is