반려동물 정보 db등록 (파이어베이스)
@ -7,7 +7,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.app"
|
||||
namespace = "com.daoblock.rup"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@ -22,7 +22,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.rup"
|
||||
applicationId = "com.daoblock.rup"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
||||
@ -5,6 +5,43 @@
|
||||
"storage_bucket": "rup-project-9d4c4.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:379988243470:android:348749e6ea9404d61cb85f",
|
||||
"android_client_info": {
|
||||
"package_name": "com.daoblock.rup"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "379988243470-gdevefapm92n317jo6rri67n83beql44.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.daoblock.rup",
|
||||
"certificate_hash": "7bda16a71840f93ab7c41fecc24da2bde3702424"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "379988243470-g6490l8gucc3ljras93i28c3l4qlroi4.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyAql00w1JB6n5wb4StXi9eyixVyIuT3-Hg"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "379988243470-g6490l8gucc3ljras93i28c3l4qlroi4.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:379988243470:android:b4a4761b906eb44a1cb85f",
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
package com.daoblock.rup
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@ -1,5 +1,3 @@
|
||||
package com.example.app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
// This file should be deleted.
|
||||
// package com.example.app
|
||||
// Moved to com.daoblock.rup
|
||||
|
||||
|
Before Width: | Height: | Size: 485 B After Width: | Height: | Size: 485 B |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
3
app/assets/icons/general_schedule_icon.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="20" height="20" rx="3.87222" fill="#FBB800"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 162 B |
|
Before Width: | Height: | Size: 741 B After Width: | Height: | Size: 741 B |
|
Before Width: | Height: | Size: 437 B After Width: | Height: | Size: 437 B |
3
app/assets/icons/important_schedule_icon.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="20" height="20" rx="3.87222" fill="#FF7500"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 162 B |
3
app/assets/icons/incomplete_icon.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="20" height="20" rx="3.87222" fill="#C8C8C8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 162 B |
|
Before Width: | Height: | Size: 740 B After Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 459 B After Width: | Height: | Size: 459 B |
|
Before Width: | Height: | Size: 203 B After Width: | Height: | Size: 203 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@ -368,7 +368,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.daoblock.rup;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@ -384,7 +384,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.daoblock.rup.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@ -401,7 +401,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.daoblock.rup.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@ -416,7 +416,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.daoblock.rup.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@ -547,7 +547,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.daoblock.rup;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@ -569,7 +569,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.daoblock.rup;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
135
app/lib/data/pet_data.dart
Normal file
@ -0,0 +1,135 @@
|
||||
class PetData {
|
||||
static const List<String> diseaseList = [
|
||||
"피부질환",
|
||||
"눈 질환",
|
||||
"치아 / 구강 질환",
|
||||
"뼈 / 관절 질환",
|
||||
"생식기 / 비뇨기 질환",
|
||||
"심장 / 혈관 질환",
|
||||
"소화기 질환",
|
||||
"호흡기 질환",
|
||||
"내분비계 질환",
|
||||
"뇌신경 질환",
|
||||
"생식기 질환",
|
||||
"귀 질환",
|
||||
"코 질환",
|
||||
"기타",
|
||||
];
|
||||
|
||||
static const Map<String, Map<String, List<String>>> breedsData = {
|
||||
"포유류": {
|
||||
"강아지": [
|
||||
"말티즈",
|
||||
"푸들",
|
||||
"포메라니안",
|
||||
"믹스견",
|
||||
"치와와",
|
||||
"시츄",
|
||||
"비숑 프리제",
|
||||
"골든 리트리버",
|
||||
"진돗개",
|
||||
"웰시 코기",
|
||||
"프렌치 불독",
|
||||
"시바견",
|
||||
"닥스후트",
|
||||
"요크셔 테리어",
|
||||
"보더 콜리",
|
||||
"사모예드",
|
||||
"허스키",
|
||||
"말라뮤트",
|
||||
"기타(직접 입력)",
|
||||
],
|
||||
"고양이": [
|
||||
"코리안 숏헤어",
|
||||
"브리티시 숏헤어",
|
||||
"아메리칸 숏헤어",
|
||||
"뱅갈",
|
||||
"메인쿤",
|
||||
"데본 렉스",
|
||||
"페르시안",
|
||||
"러시안 블루",
|
||||
"샴",
|
||||
"렉돌",
|
||||
"스코티시 폴드",
|
||||
"먼치킨",
|
||||
"노르웨이 숲",
|
||||
"믹스묘",
|
||||
"기타(직접 입력)",
|
||||
],
|
||||
"햄스터": ["정글리안", "펄", "푸딩", "골든 햄스터", "로보로브스키", "기타(직접 입력)"],
|
||||
"토끼": ["롭이어", "더치", "라이언 헤드", "드워프", "렉스", "기타(직접 입력)"],
|
||||
"기니피그": ["잉글리쉬", "아비시니안", "페루비안", "실키", "기타(직접 입력)"],
|
||||
"고슴도치": ["플라티나", "화이트 초코", "알비노", "핀토", "기타(직접 입력)"],
|
||||
"기타(직접 입력)": ["기타(직접 입력)"],
|
||||
},
|
||||
"파충류": {
|
||||
"거북이": ["커먼 머스크 터틀", "레이저백", "육지거북", "붉은귀거북", "남생이", "기타(직접 입력)"],
|
||||
"도마뱀": [
|
||||
"크레스티드 게코",
|
||||
"리키에너스 게코",
|
||||
"가고일 게코",
|
||||
"레오파드 게코",
|
||||
"비어디 드래곤",
|
||||
"블루텅 스킨크",
|
||||
"이구아나",
|
||||
"기타(직접 입력)",
|
||||
],
|
||||
"뱀": [
|
||||
"볼 파이톤",
|
||||
"가터 스네이크",
|
||||
"호그노즈 스네이크",
|
||||
"콘 스네이크",
|
||||
"킹 스네이크",
|
||||
"밀크 스네이크",
|
||||
"기타(직접 입력)",
|
||||
],
|
||||
"기타(직접 입력)": ["기타(직접 입력)"],
|
||||
},
|
||||
"조류": {
|
||||
"앵무새(소/중형)": [
|
||||
"사랑앵무(잉꼬)",
|
||||
"코카티엘(왕관앵무)",
|
||||
"모란앵무",
|
||||
"코뉴어",
|
||||
"퀘이커",
|
||||
"카카리키",
|
||||
"사자나미(빗금앵무)",
|
||||
"유리앵무",
|
||||
"기타(직접 입력)",
|
||||
],
|
||||
"앵무새(대형)": [
|
||||
"뉴기니아",
|
||||
"회색앵무",
|
||||
"금강앵무(마카우)",
|
||||
"유황앵무(코카투)",
|
||||
"아마존앵무",
|
||||
"대본영",
|
||||
"기타(직접 입력)",
|
||||
],
|
||||
"핀치/관상조": ["카나리아", "십자매", "문조", "금화조", "호금조", "백문조", "기타(직접 입력)"],
|
||||
"비둘기/닭/메추리": [
|
||||
"애완용 비둘기",
|
||||
"관상닭(실키 등)",
|
||||
"메추리",
|
||||
"미니메추리",
|
||||
"오리/거위",
|
||||
"기타(직접 입력)",
|
||||
],
|
||||
"기타(직접 입력)": ["직접 입력"],
|
||||
},
|
||||
"양서류": {
|
||||
"개구리": ["청개구리", "팩맨", "다트 프록", "화이트 트리 프록", "기타(직접 입력)"],
|
||||
"도룡뇽": ["우파루파", "파이어 벨리 뉴트", "타이거 살라만더", "기타(직접 입력)"],
|
||||
"기타(직접 입력)": ["기타(직접 입력)"],
|
||||
},
|
||||
"어류": {
|
||||
"열대어": ["구피", "베타", "테트라", "디스커스", "엔젤피쉬", "기타(직접 입력)"],
|
||||
"금붕어/잉어": ["금붕어", "비단잉어", "기타(직접 입력)"],
|
||||
"해수어": ["크라운피쉬(니모)", "블루탱", "기타(직접 입력)"],
|
||||
"기타(직접 입력)": ["기타(직접 입력)"],
|
||||
},
|
||||
"기타(직접 입력)": {
|
||||
"기타(직접 입력)": ["기타(직접 입력)"],
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'dart:developer';
|
||||
import 'screens/splash_screen.dart';
|
||||
import 'screens/register_complete_screen.dart';
|
||||
import 'utils/log_manager.dart';
|
||||
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
@ -45,6 +46,9 @@ class RupApp extends StatelessWidget {
|
||||
navigatorKey: navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: const SplashScreen(),
|
||||
routes: {
|
||||
'/register_complete': (context) => const RegisterCompleteScreen(),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
79
app/lib/models/pet_model.dart
Normal file
@ -0,0 +1,79 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class Pet {
|
||||
final String id;
|
||||
final String ownerId;
|
||||
final String name;
|
||||
final String species; // "강아지", "고양이", etc.
|
||||
final String breed; // "말티즈", "코숏", etc.
|
||||
final String gender; // "남아", "여아"
|
||||
final bool isNeutered;
|
||||
final DateTime? birthDate;
|
||||
final bool isDateUnknown;
|
||||
final String? registrationNumber;
|
||||
final String? profileImageUrl;
|
||||
final List<String> diseases;
|
||||
final List<String> pastDiseases;
|
||||
final List<String> healthConcerns;
|
||||
final DateTime createdAt;
|
||||
|
||||
Pet({
|
||||
required this.id,
|
||||
required this.ownerId,
|
||||
required this.name,
|
||||
required this.species,
|
||||
required this.breed,
|
||||
required this.gender,
|
||||
required this.isNeutered,
|
||||
this.birthDate,
|
||||
required this.isDateUnknown,
|
||||
this.registrationNumber,
|
||||
this.profileImageUrl,
|
||||
required this.diseases,
|
||||
required this.pastDiseases,
|
||||
required this.healthConcerns,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'ownerId': ownerId,
|
||||
'name': name,
|
||||
'species': species,
|
||||
'breed': breed,
|
||||
'gender': gender,
|
||||
'isNeutered': isNeutered,
|
||||
'birthDate': birthDate != null ? Timestamp.fromDate(birthDate!) : null,
|
||||
'isDateUnknown': isDateUnknown,
|
||||
'registrationNumber': registrationNumber,
|
||||
'profileImageUrl': profileImageUrl,
|
||||
'diseases': diseases,
|
||||
'pastDiseases': pastDiseases,
|
||||
'healthConcerns': healthConcerns,
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
factory Pet.fromMap(Map<String, dynamic> map) {
|
||||
return Pet(
|
||||
id: map['id'] ?? '',
|
||||
ownerId: map['ownerId'] ?? '',
|
||||
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,
|
||||
registrationNumber: map['registrationNumber'],
|
||||
profileImageUrl: map['profileImageUrl'],
|
||||
diseases: List<String>.from(map['diseases'] ?? []),
|
||||
pastDiseases: List<String>.from(map['pastDiseases'] ?? []),
|
||||
healthConcerns: List<String>.from(map['healthConcerns'] ?? []),
|
||||
createdAt: (map['createdAt'] as Timestamp).toDate(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,71 +1,320 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import screenutil
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'pet_registration_screen.dart';
|
||||
import '../services/firestore_service.dart';
|
||||
import '../models/pet_model.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
final FirestoreService _firestoreService = FirestoreService();
|
||||
String? _userId;
|
||||
Pet? _selectedPet;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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) {
|
||||
return const Scaffold(body: Center(child: Text('로그인이 필요합니다.')));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 20.h),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(
|
||||
8.r,
|
||||
), // Optional: Add radius
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const PetRegistrationScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min, // Wrap content only
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/img/profile.png',
|
||||
width: 40.w,
|
||||
height: 40.h,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Text(
|
||||
'반려동물 등록 +',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 15.sp,
|
||||
letterSpacing: 0.45.sp,
|
||||
color: const Color(0xFF1f1f1f),
|
||||
child: StreamBuilder<List<Pet>>(
|
||||
stream: _firestoreService.getPets(_userId!),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('오류가 발생했습니다: ${snapshot.error}'));
|
||||
}
|
||||
|
||||
final pets = snapshot.data ?? [];
|
||||
|
||||
// 등록된 반려동물이 없을 때: 기존 UI 유지 (등록 버튼 강조)
|
||||
if (pets.isEmpty) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w,
|
||||
vertical: 20.h,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
const PetRegistrationScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4.h),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/img/profile.png',
|
||||
width: 40.w,
|
||||
height: 40.h,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Text(
|
||||
'반려동물 등록 +',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 15.sp,
|
||||
letterSpacing: 0.45.sp,
|
||||
color: const Color(0xFF1f1f1f),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'등록된 반려동물이 없습니다.\n새로운 가족을 등록해주세요!',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontSize: 16.sp,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 등록된 반려동물이 있을 때
|
||||
// 선택된 펫이 없거나 리스트에 없으면 첫 번째 펫 선택
|
||||
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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w,
|
||||
vertical: 20.h,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showPetSelectionModal(pets),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 프로필 이미지
|
||||
CircleAvatar(
|
||||
radius: 20.r,
|
||||
backgroundColor: Colors.grey[200],
|
||||
backgroundImage: displayPet.profileImageUrl != null
|
||||
? NetworkImage(displayPet.profileImageUrl!)
|
||||
: null,
|
||||
child: displayPet.profileImageUrl == null
|
||||
? SvgPicture.asset(
|
||||
'assets/icons/profile_icon.svg',
|
||||
width: 20.w,
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.grey[400]!,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
// 이름
|
||||
Text(
|
||||
displayPet.name,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18.sp,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
// 드롭다운 화살표
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 24.w,
|
||||
color: Colors.black,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'로그인 성공!\n여기는 메인 홈 화면입니다.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 18.sp,
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'안녕, ${displayPet.name}!',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -156,7 +156,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
fontSize: 15.sp,
|
||||
backgroundColor: const Color(0xFF00D03F),
|
||||
onPressed: () {},
|
||||
iconPath: 'assets/icons/navericon.svg',
|
||||
iconPath: 'assets/icons/naver_icon.svg',
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
// Kakao Login Button
|
||||
@ -168,7 +168,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
fontSize: 15.sp,
|
||||
backgroundColor: const Color(0xFFFAE100),
|
||||
onPressed: () {},
|
||||
iconPath: 'assets/icons/kakaoicon.svg',
|
||||
iconPath: 'assets/icons/kakao_icon.svg',
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
// Google Login Button
|
||||
@ -180,7 +180,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
fontSize: 15.sp,
|
||||
backgroundColor: Colors.white,
|
||||
onPressed: _handleGoogleLogin,
|
||||
iconPath: 'assets/icons/googleicon.svg',
|
||||
iconPath: 'assets/icons/google_icon.svg',
|
||||
isBordered: true,
|
||||
),
|
||||
],
|
||||
|
||||
@ -87,14 +87,14 @@ class _MainScreenState extends State<MainScreen> {
|
||||
BottomNavigationBarItem(
|
||||
icon: Padding(
|
||||
padding: EdgeInsets.only(bottom: 10.h),
|
||||
child: _buildSvgIcon('assets/icons/homeicon.svg', 0),
|
||||
child: _buildSvgIcon('assets/icons/home_icon.svg', 0),
|
||||
),
|
||||
label: '홈',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Padding(
|
||||
padding: EdgeInsets.only(bottom: 10.h),
|
||||
child: _buildSvgIcon('assets/icons/appointmenticon.svg', 1),
|
||||
child: _buildSvgIcon('assets/icons/appointment_icon.svg', 1),
|
||||
),
|
||||
label: '예약/조회',
|
||||
),
|
||||
@ -115,14 +115,14 @@ class _MainScreenState extends State<MainScreen> {
|
||||
BottomNavigationBarItem(
|
||||
icon: Padding(
|
||||
padding: EdgeInsets.only(bottom: 10.h),
|
||||
child: _buildSvgIcon('assets/icons/shopicon.svg', 3),
|
||||
child: _buildSvgIcon('assets/icons/shop_icon.svg', 3),
|
||||
),
|
||||
label: '상점',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Padding(
|
||||
padding: EdgeInsets.only(bottom: 10.h),
|
||||
child: _buildSvgIcon('assets/icons/myicon.svg', 4),
|
||||
child: _buildSvgIcon('assets/icons/my_icon.svg', 4),
|
||||
),
|
||||
label: '내 정보',
|
||||
),
|
||||
|
||||
@ -5,6 +5,11 @@ 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 Data
|
||||
import '../widgets/pet_registration/selection_modal.dart'; // Import SelectionModal
|
||||
import '../widgets/pet_registration/input_formatters.dart'; // Import InputFormatters
|
||||
import '../services/firestore_service.dart';
|
||||
import '../models/pet_model.dart';
|
||||
|
||||
class PetRegistrationScreen extends StatefulWidget {
|
||||
const PetRegistrationScreen({super.key});
|
||||
@ -17,27 +22,53 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
// 정확한 날짜를 몰라요 상태
|
||||
bool _isDateUnknown = false;
|
||||
|
||||
final TextEditingController _yearController = TextEditingController();
|
||||
final TextEditingController _monthController = TextEditingController();
|
||||
final TextEditingController _yearController =
|
||||
TextEditingController(); // Added back
|
||||
final TextEditingController _dayController = TextEditingController();
|
||||
final TextEditingController _registrationNumberController =
|
||||
TextEditingController(); // Added Registration Number Controller
|
||||
final FocusNode _yearFocus = FocusNode();
|
||||
final FocusNode _monthFocus = FocusNode();
|
||||
final FocusNode _dayFocus = FocusNode();
|
||||
|
||||
// 보유 질환 데이터
|
||||
final List<String> _diseaseList = [
|
||||
"피부질환",
|
||||
"눈 질환",
|
||||
"치아 / 구강 질환",
|
||||
"뼈 / 관절 질환",
|
||||
"생식기 / 비뇨기 질환",
|
||||
"심장 / 혈관 질환",
|
||||
"소화기 질환",
|
||||
"호흡기 질환",
|
||||
"내분비계 질환",
|
||||
"뇌신경 질환",
|
||||
"생식기 질환",
|
||||
"귀 질환",
|
||||
"코 질환",
|
||||
"기타",
|
||||
];
|
||||
bool get _isFormValid {
|
||||
// 1. 이름 확인
|
||||
if (_nameController.text.trim().isEmpty) return false;
|
||||
// 2. 종 확인
|
||||
if (_speciesController.text.trim().isEmpty) return false;
|
||||
// 3. 품종 확인
|
||||
if (_breedController.text.trim().isEmpty) return false;
|
||||
// 4. 성별 확인
|
||||
if (_selectedGender == null) return false;
|
||||
// 5. 생년월일 확인
|
||||
if (!_isDateUnknown) {
|
||||
if (_yearController.text.length != 4 ||
|
||||
_monthController.text.length != 2 ||
|
||||
_dayController.text.length != 2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 폼 상태 변경 감지를 위한 리스너 등록
|
||||
_nameController.addListener(_updateState);
|
||||
_speciesController.addListener(_updateState);
|
||||
_breedController.addListener(_updateState);
|
||||
_yearController.addListener(_updateState);
|
||||
_monthController.addListener(_updateState);
|
||||
_dayController.addListener(_updateState);
|
||||
}
|
||||
|
||||
void _updateState() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
// 보유 질환 데이터 (Removed: Use PetData.diseaseList)
|
||||
|
||||
// 각 항목별 선택 상태 및 컨트롤러
|
||||
final TextEditingController _nameController =
|
||||
@ -49,85 +80,7 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
final TextEditingController _genderController =
|
||||
TextEditingController(); // 성별 컨트롤러
|
||||
|
||||
// 종 데이터 (대분류 -> 중분류 -> 품종)
|
||||
final Map<String, Map<String, List<String>>> _petData = {
|
||||
"포유류": {
|
||||
"강아지": [
|
||||
"말티즈",
|
||||
"푸들",
|
||||
"포메라니안",
|
||||
"믹스견",
|
||||
"치와와",
|
||||
"시츄",
|
||||
"비숑 프리제",
|
||||
"골든 리트리버",
|
||||
"진돗개",
|
||||
"웰시 코기",
|
||||
"기타(직접 입력)",
|
||||
],
|
||||
"고양이": [
|
||||
"코리안 숏헤어",
|
||||
"페르시안",
|
||||
"러시안 블루",
|
||||
"샴",
|
||||
"렉돌",
|
||||
"스코티시 폴드",
|
||||
"먼치킨",
|
||||
"노르웨이 숲",
|
||||
"믹스묘",
|
||||
"기타(직접 입력)",
|
||||
],
|
||||
"햄스터": ["정글리안", "펄", "푸딩", "골든 햄스터", "로보로브스키", "기타(직접 입력)"],
|
||||
"토끼": ["롭이어", "더치", "라이언 헤드", "드워프", "렉스", "기타(직접 입력)"],
|
||||
"기니피그": ["잉글리쉬", "아비시니안", "페루비안", "실키", "기타(직접 입력)"],
|
||||
"고슴도치": ["플라티나", "화이트 초코", "알비노", "핀토", "기타(직접 입력)"],
|
||||
"기타": ["기타(직접 입력)"],
|
||||
},
|
||||
"파충류": {
|
||||
"거북이": ["커먼 머스크 터틀", "레이저백", "육지거북", "붉은귀거북", "남생이", "기타(직접 입력)"],
|
||||
"도마뱀": ["크레스티드 게코", "레오파드 게코", "비어디 드래곤", "블루텅 스킨크", "이구아나", "기타(직접 입력)"],
|
||||
"뱀": ["볼 파이톤", "콘 스네이크", "킹 스네이크", "밀크 스네이크", "기타(직접 입력)"],
|
||||
"기타": ["기타(직접 입력)"],
|
||||
},
|
||||
"조류": {
|
||||
"앵무새": [
|
||||
"사랑앵무(잉꼬)",
|
||||
"코카티엘(왕관앵무)",
|
||||
"모란앵무",
|
||||
"코뉴어",
|
||||
"퀘이커",
|
||||
"금강앵무",
|
||||
"기타(직접 입력)",
|
||||
],
|
||||
"카나리아": ["옐로우 카나리아", "레드 카나리아", "보더 카나리아", "기타(직접 입력)"],
|
||||
"핀치": ["문조", "십자매", "금화조", "호금조", "기타(직접 입력)"],
|
||||
"기타": ["기타(직접 입력)"],
|
||||
},
|
||||
"어류": {
|
||||
"금붕어": ["오란다", "유금", "단정", "진주린", "코메트", "기타(직접 입력)"],
|
||||
"열대어": ["네온 테트라", "엔젤피쉬", "플래티", "몰리", "디스커스", "기타(직접 입력)"],
|
||||
"구피": ["고정 구피", "막구피(믹스)", "기타(직접 입력)"],
|
||||
"잉어": ["비단잉어", "향어", "기타(직접 입력)"],
|
||||
"기타": ["기타(직접 입력)"],
|
||||
},
|
||||
"곤충": {
|
||||
"장수풍뎅이": ["국산 장수풍뎅이", "헤라클레스 장수풍뎅이", "코카서스 장수풍뎅이", "기타(직접 입력)"],
|
||||
"사슴벌레": ["넓적사슴벌레", "왕사슴벌레", "톱사슴벌레", "애사슴벌레", "기타(직접 입력)"],
|
||||
"나비/나방": ["배추흰나비", "호랑나비", "누에나방", "기타(직접 입력)"],
|
||||
"사마귀": ["왕사마귀", "사마귀", "넓적배사마귀", "기타(직접 입력)"],
|
||||
"기타": ["기타(직접 입력)"],
|
||||
},
|
||||
"절지동물": {
|
||||
"타란툴라(거미)": ["로즈헤어", "골든니", "화이트니", "핑크토", "기타(직접 입력)"],
|
||||
"전갈": ["황제전갈", "극동전갈", "아시안 포레스트 전갈", "기타(직접 입력)"],
|
||||
"지네": ["왕지네", "청지네", "기타(직접 입력)"],
|
||||
"소라게": ["인도 소라게", "딸기 소라게", "바이오라센트", "기타(직접 입력)"],
|
||||
"기타": ["기타(직접 입력)"],
|
||||
},
|
||||
"기타": {
|
||||
"기타(직접 입력)": ["기타(직접 입력)"],
|
||||
},
|
||||
};
|
||||
// 종 데이터 (Removed: Use PetData.breedsData)
|
||||
|
||||
// 선택된 종 정보 (품종 선택을 위해 필요)
|
||||
String? _currentMajorCategory;
|
||||
@ -211,6 +164,27 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
}
|
||||
},
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
@ -239,9 +213,98 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
_diseaseController.dispose();
|
||||
_pastDiseaseController.dispose();
|
||||
_healthConcernController.dispose();
|
||||
_yearFocus.dispose();
|
||||
_monthFocus.dispose();
|
||||
_dayFocus.dispose();
|
||||
_registrationNumberController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
Future<void> _registerPet() 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)');
|
||||
}
|
||||
|
||||
final newPet = Pet(
|
||||
id: firestoreService.generatePetId(),
|
||||
ownerId: userId,
|
||||
name: _nameController.text,
|
||||
species: _speciesController.text, // 중분류 or 직접입력
|
||||
breed: _breedController.text,
|
||||
gender: _selectedGender!,
|
||||
isNeutered: _isNeutered,
|
||||
birthDate: birthDate,
|
||||
isDateUnknown: _isDateUnknown,
|
||||
registrationNumber: _registrationNumberController.text.isNotEmpty
|
||||
? _registrationNumberController.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;
|
||||
@ -301,10 +364,10 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
// 리스트 영역
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _diseaseList.length,
|
||||
itemCount: PetData.diseaseList.length,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final disease = _diseaseList[index];
|
||||
final disease = PetData.diseaseList[index];
|
||||
final isSelected = tempSelected.contains(disease);
|
||||
|
||||
return Column(
|
||||
@ -523,7 +586,7 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 뒤로가기 버튼 (대분류 선택 상태이거나 입력창 상태일 때 표시)
|
||||
// 뒤로가기 버튼
|
||||
(selectedMajor != null || showInput)
|
||||
? GestureDetector(
|
||||
onTap: () {
|
||||
@ -644,11 +707,10 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
: (selectedMajor == null
|
||||
? ListView.builder(
|
||||
// 대분류 리스트
|
||||
itemCount: _petData.keys.length,
|
||||
itemCount: PetData.breedsData.keys.length,
|
||||
itemBuilder: (context, index) {
|
||||
final major = _petData.keys.elementAt(
|
||||
index,
|
||||
);
|
||||
final major = PetData.breedsData.keys
|
||||
.elementAt(index);
|
||||
return ListTile(
|
||||
title: Text(
|
||||
major,
|
||||
@ -664,7 +726,7 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
),
|
||||
onTap: () {
|
||||
setModalState(() {
|
||||
if (major == '기타') {
|
||||
if (major == '기타(직접 입력)') {
|
||||
showInput = true;
|
||||
} else {
|
||||
selectedMajor = major;
|
||||
@ -676,9 +738,12 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
)
|
||||
: ListView.builder(
|
||||
// 중분류 리스트
|
||||
itemCount: _petData[selectedMajor]!.length,
|
||||
itemCount:
|
||||
PetData.breedsData[selectedMajor]!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final minor = _petData[selectedMajor]!.keys
|
||||
final minor = PetData
|
||||
.breedsData[selectedMajor]!
|
||||
.keys
|
||||
.elementAt(index);
|
||||
return ListTile(
|
||||
title: Text(
|
||||
@ -746,10 +811,10 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
}
|
||||
|
||||
// 3. 품종 리스트 가져오기
|
||||
final List<String> originalList =
|
||||
_petData[_currentMajorCategory]![_currentMinorCategory]!
|
||||
.where((e) => e != '기타(직접 입력)')
|
||||
.toList();
|
||||
final List<String> originalList = PetData
|
||||
.breedsData[_currentMajorCategory]![_currentMinorCategory]!
|
||||
.where((e) => e != '기타(직접 입력)')
|
||||
.toList();
|
||||
// '기타(직접 입력)'은 리스트 마지막에 고정하거나 별도 처리, 여기서는 필터링 후 맨 뒤에 붙일 예정
|
||||
|
||||
showModalBottomSheet(
|
||||
@ -1359,7 +1424,7 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
child: _profileImage == null
|
||||
? Center(
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/profileicon.svg',
|
||||
'assets/icons/profile_icon.svg',
|
||||
width: 40.w,
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.grey[400]!,
|
||||
@ -1401,15 +1466,14 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
|
||||
// 2. 반려동물 이름 입력
|
||||
_buildLabel('반려동물 이름 입력', isRequired: true),
|
||||
SizedBox(height: 8.h),
|
||||
_buildTextField(
|
||||
controller: _nameController,
|
||||
hint: '이름 입력 (2~10글자/영문/숫자/한글)',
|
||||
hint: '이름 입력 (2~10글자/한글/영문/숫자)',
|
||||
inputFormatters: [
|
||||
LengthLimitingTextInputFormatter(10), // 최대 10글자 제한
|
||||
],
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// 3. 선택 박스들 (종, 품종, 성별)
|
||||
_buildSearchField(
|
||||
@ -1417,31 +1481,34 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
controller: _speciesController,
|
||||
readOnly: true,
|
||||
onTap: _showSpeciesSelectionModal,
|
||||
isRequired: true,
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
SizedBox(height: 20.h),
|
||||
_buildSearchField(
|
||||
'반려동물 품종 선택',
|
||||
controller: _breedController,
|
||||
readOnly: true,
|
||||
onTap: _showBreedSelectionModal,
|
||||
isRequired: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(height: 20.h),
|
||||
_buildSearchField(
|
||||
'반려동물 성별',
|
||||
controller: _genderController,
|
||||
readOnly: true,
|
||||
onTap: _showGenderSelectionModal,
|
||||
isRequired: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// 4. 생년월일
|
||||
_buildLabel('반려동물 생년월일', isRequired: true),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _yearController,
|
||||
focusNode: _yearFocus,
|
||||
hint: 'YYYY',
|
||||
textAlign: TextAlign.center,
|
||||
hintColor: _isDateUnknown
|
||||
@ -1453,12 +1520,18 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
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
|
||||
@ -1469,14 +1542,20 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(2),
|
||||
_DateRangeInputFormatter(min: 1, max: 12),
|
||||
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
|
||||
@ -1487,7 +1566,7 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(2),
|
||||
_DayInputFormatter(
|
||||
DayInputFormatter(
|
||||
monthController: _monthController,
|
||||
yearController: _yearController,
|
||||
),
|
||||
@ -1525,9 +1604,10 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
|
||||
// 5. 동물 등록 번호
|
||||
_buildLabel('동물 등록 번호', isRequired: false),
|
||||
const SizedBox(height: 8),
|
||||
// const SizedBox(height: 8),
|
||||
_buildTextField(
|
||||
hint: '동물 등록 번호 입력',
|
||||
controller: _registrationNumberController,
|
||||
hint: '숫자만 입력',
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
@ -1564,7 +1644,7 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(height: 20.h),
|
||||
_buildSearchField(
|
||||
'과거 진단받은 질병',
|
||||
controller: _pastDiseaseController,
|
||||
@ -1591,7 +1671,7 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(height: 20.h),
|
||||
_buildSearchField(
|
||||
'염려되는 건강 문제',
|
||||
controller: _healthConcernController,
|
||||
@ -1626,23 +1706,34 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
width: double.infinity,
|
||||
height: 52.h,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {},
|
||||
onPressed: (_isFormValid && !_isLoading) ? _registerPet : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.highlight,
|
||||
disabledBackgroundColor: AppColors.inactive,
|
||||
disabledForegroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30.r),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'반려동물 등록',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? SizedBox(
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'반려동물 등록',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
@ -1655,19 +1746,27 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
// 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: Icon(Icons.circle, size: 4.w, color: Colors.red),
|
||||
child: Container(
|
||||
width: 4.w,
|
||||
height: 4.w,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -1683,9 +1782,13 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
TextEditingController? controller,
|
||||
TextInputType? keyboardType,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
FocusNode? focusNode,
|
||||
ValueChanged<String>? onChanged,
|
||||
}) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
onChanged: onChanged,
|
||||
enabled: enabled,
|
||||
textAlign: textAlign,
|
||||
keyboardType: keyboardType,
|
||||
@ -1750,19 +1853,15 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
TextEditingController? controller,
|
||||
VoidCallback? onTap,
|
||||
bool readOnly = false,
|
||||
bool isRequired = false, // Added isRequired param
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_buildLabel(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
isRequired: isRequired,
|
||||
), // Use _buildLabel to show label with red dot
|
||||
TextField(
|
||||
controller: controller, // 컨트롤러 연결
|
||||
onTap: onTap, // 탭 이벤트 연결
|
||||
@ -1792,86 +1891,3 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 범위 제한 Formatter (월: 1~12)
|
||||
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 < 0) {
|
||||
return oldValue;
|
||||
}
|
||||
|
||||
// 입력 중에는 자릿수 제한이 있으므로, max값 초과 여부만 확인
|
||||
if (value > max) {
|
||||
return oldValue;
|
||||
}
|
||||
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 일(Day) 입력 Formatter (월에 따라 28~31일 제한)
|
||||
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 maxDay = 31;
|
||||
final int? month = int.tryParse(monthController.text);
|
||||
|
||||
if (month != null) {
|
||||
if (month == 2) {
|
||||
maxDay = 29; // 기본 29일
|
||||
// 윤년 계산
|
||||
final int? year = int.tryParse(yearController.text);
|
||||
if (year != null) {
|
||||
bool isLeap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
|
||||
maxDay = isLeap ? 29 : 28;
|
||||
}
|
||||
} else if ([4, 6, 9, 11].contains(month)) {
|
||||
maxDay = 30;
|
||||
}
|
||||
}
|
||||
|
||||
if (day > maxDay) {
|
||||
return oldValue;
|
||||
}
|
||||
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
|
||||
87
app/lib/screens/register_complete_screen.dart
Normal file
@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'main_screen.dart';
|
||||
|
||||
class RegisterCompleteScreen extends StatelessWidget {
|
||||
const RegisterCompleteScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(),
|
||||
// TODO: Add a success image/icon here if available
|
||||
Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 100.w,
|
||||
color: AppColors.highlight,
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
Text(
|
||||
'반려동물 등록 완료!',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF1F1F1F),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
'이제 RUP과 함께\n똑똑한 반려생활을 시작해보세요.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF7D7C7C),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52.h,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
// 메인 화면으로 이동하면서 스택 초기화
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const MainScreen(),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.highlight,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30.r),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'홈으로 가기',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -193,11 +193,13 @@ class AuthService {
|
||||
await _storage.write(key: 'accessToken', value: accessToken);
|
||||
await _storage.write(key: 'refreshToken', value: refreshToken);
|
||||
|
||||
// Firebase 로그인 처리 (필요시) - idToken으로 credential 생성 가능하지만 access token이 없으므로 생략하거나
|
||||
// 이미 signInWithGoogle에서 받아온 credential을 재사용할 수 있으면 좋음.
|
||||
// 하지만 여기서는 백엔드 세션이 중요하므로 일단 백엔드 토큰만 저장.
|
||||
// (Firebase Auth와 Custom Backend Auth를 혼용 중이라 복잡함.
|
||||
// 일단 백엔드 로직이 메인이므로 백엔드 토큰 처리에 집중)
|
||||
// Firebase 로그인 처리
|
||||
// idToken으로 Credential 생성하여 로그인
|
||||
final OAuthCredential credential = GoogleAuthProvider.credential(
|
||||
idToken: idToken,
|
||||
accessToken: null, // accessToken은 필수가 아님 (idToken만으로 가능)
|
||||
);
|
||||
await _auth.signInWithCredential(credential);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
98
app/lib/services/firestore_service.dart
Normal file
@ -0,0 +1,98 @@
|
||||
import 'dart:io';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/pet_model.dart';
|
||||
import '../utils/log_manager.dart';
|
||||
|
||||
class FirestoreService {
|
||||
final FirebaseFirestore _db = FirebaseFirestore.instance;
|
||||
final FirebaseStorage _storage = FirebaseStorage.instance;
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
|
||||
// 반려동물 등록
|
||||
Future<void> registerPet(Pet pet, File? imageFile) async {
|
||||
try {
|
||||
String? imageUrl;
|
||||
|
||||
// 1. 이미지 업로드 (이미지가 있는 경우)
|
||||
if (imageFile != null) {
|
||||
final String fileName =
|
||||
'${pet.id}_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final Reference storageRef = _storage
|
||||
.ref()
|
||||
.child('pet_images')
|
||||
.child(fileName);
|
||||
|
||||
LogManager().addLog(
|
||||
'[Storage] Starting upload to ${storageRef.fullPath}',
|
||||
);
|
||||
LogManager().addLog('[Storage] Bucket: ${_storage.bucket}');
|
||||
|
||||
final TaskSnapshot snapshot = await storageRef.putFile(imageFile);
|
||||
|
||||
if (snapshot.state == TaskState.success) {
|
||||
LogManager().addLog(
|
||||
'[Storage] Upload success. Validating metadata...',
|
||||
);
|
||||
// 잠시 대기 (consistency 이슈 방지)
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
imageUrl = await storageRef.getDownloadURL();
|
||||
LogManager().addLog('[Storage] Download URL retrieved: $imageUrl');
|
||||
} else {
|
||||
throw Exception('이미지 업로드 실패 (상태: ${snapshot.state})');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Pet 객체에 이미지 URL 업데이트 (새로운 객체 생성)
|
||||
Pet petWithImage = Pet(
|
||||
id: pet.id,
|
||||
ownerId: pet.ownerId,
|
||||
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, // 이미지 URL 설정
|
||||
diseases: pet.diseases,
|
||||
pastDiseases: pet.pastDiseases,
|
||||
healthConcerns: pet.healthConcerns,
|
||||
createdAt: pet.createdAt,
|
||||
);
|
||||
|
||||
// 3. Firestore에 저장
|
||||
await _db.collection('pets').doc(pet.id).set(petWithImage.toMap());
|
||||
|
||||
LogManager().addLog('[Firestore] Pet registered successfully: ${pet.id}');
|
||||
} catch (e) {
|
||||
LogManager().addLog('[Firestore] Error registering pet: $e');
|
||||
throw Exception('반려동물 등록 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 로그인한 사용자의 ID 가져오기
|
||||
String? getCurrentUserId() {
|
||||
return _auth.currentUser?.uid;
|
||||
}
|
||||
|
||||
// 반려동물 리스트 스트림 가져오기
|
||||
Stream<List<Pet>> getPets(String userId) {
|
||||
return _db
|
||||
.collection('pets')
|
||||
.where('ownerId', isEqualTo: userId)
|
||||
.orderBy('createdAt', descending: true) // 최신 등록순
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
return snapshot.docs.map((doc) => Pet.fromMap(doc.data())).toList();
|
||||
});
|
||||
}
|
||||
|
||||
// 새 Pet ID 생성
|
||||
String generatePetId() {
|
||||
return const Uuid().v4();
|
||||
}
|
||||
}
|
||||
78
app/lib/widgets/pet_registration/input_formatters.dart
Normal file
@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
// 범위 제한 Formatter (월: 1~12)
|
||||
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) {
|
||||
// 범위를 벗어나면 이전 값 유지 (단, 입력 중인 상태 고려 - 예: 3을 입력하려는데 30이 되면 안됨)
|
||||
// 여기서는 단순하게 max보다 크면 입력 불가 처리 (사용자 경험상 이게 나을 수 있음)
|
||||
if (newValue.text.length > max.toString().length) {
|
||||
return oldValue;
|
||||
}
|
||||
// 더 정교한 로직이 필요할 수 있으나, 기본적으로 입력 막음
|
||||
if (value > max) return oldValue;
|
||||
}
|
||||
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 일(Day) 입력 Formatter (월에 따라 28~31일 제한)
|
||||
class DayInputFormatter extends TextInputFormatter {
|
||||
final TextEditingController yearController;
|
||||
final TextEditingController monthController;
|
||||
|
||||
DayInputFormatter({
|
||||
required this.yearController,
|
||||
required this.monthController,
|
||||
});
|
||||
|
||||
@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 year = int.tryParse(yearController.text) ?? DateTime.now().year;
|
||||
int month = int.tryParse(monthController.text) ?? 1;
|
||||
|
||||
int maxDay = DateTime(year, month + 1, 0).day; // 해당 월의 마지막 날짜 계산
|
||||
|
||||
if (day < 1 || day > maxDay) {
|
||||
if (newValue.text.length > maxDay.toString().length) {
|
||||
return oldValue;
|
||||
}
|
||||
if (day > maxDay) return oldValue;
|
||||
}
|
||||
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
215
app/lib/widgets/pet_registration/selection_modal.dart
Normal file
@ -0,0 +1,215 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
class SelectionModal extends StatefulWidget {
|
||||
final String title;
|
||||
final List<String> currentSelected;
|
||||
final String currentOtherText;
|
||||
final Function(List<String>, String) onComplete;
|
||||
final bool isSingleSelection;
|
||||
final List<String> itemList;
|
||||
|
||||
const SelectionModal({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.currentSelected,
|
||||
required this.currentOtherText,
|
||||
required this.onComplete,
|
||||
this.isSingleSelection = false,
|
||||
required this.itemList,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SelectionModal> createState() => _SelectionModalState();
|
||||
}
|
||||
|
||||
class _SelectionModalState extends State<SelectionModal> {
|
||||
late List<String> _tempSelected;
|
||||
final TextEditingController _otherController = TextEditingController();
|
||||
bool _isOtherSelected = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempSelected = List.from(widget.currentSelected);
|
||||
_otherController.text = widget.currentOtherText;
|
||||
|
||||
// "기타"가 선택되어 있는지 확인 (여기서는 단순히 리스트에 있는지로 판단하지 않고,
|
||||
// "기타"라는 항목 텍스트 자체를 체크하거나, 외부에서 넘겨받은 텍스트가 있으면 체크)
|
||||
// *로직 수정*: itemList에 "기타"가 포함되어 있다면 그것을 기준으로 함.
|
||||
if (_tempSelected.contains("기타")) {
|
||||
_isOtherSelected = true;
|
||||
} else if (widget.currentOtherText.isNotEmpty) {
|
||||
// 기존 로직상 기타 텍스트가 있으면 기타가 선택된 것으로 간주할 수도 있음
|
||||
// 하지만 리스트 기반이므로 명시적으로 "기타" 아이템이 있어야 함.
|
||||
// 여기서는 단순화하여 UI 상태만 초기화.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_otherController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onItemTap(String item) {
|
||||
setState(() {
|
||||
if (item == "기타") {
|
||||
if (widget.isSingleSelection) {
|
||||
_tempSelected.clear();
|
||||
_tempSelected.add(item);
|
||||
_isOtherSelected = true;
|
||||
} else {
|
||||
if (_tempSelected.contains(item)) {
|
||||
_tempSelected.remove(item);
|
||||
_isOtherSelected = false;
|
||||
} else {
|
||||
_tempSelected.add(item);
|
||||
_isOtherSelected = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (widget.isSingleSelection) {
|
||||
_tempSelected.clear();
|
||||
_tempSelected.add(item);
|
||||
// 단일 선택에서 다른거 누르면 기타 해제
|
||||
_isOtherSelected = false;
|
||||
} else {
|
||||
if (_tempSelected.contains(item)) {
|
||||
_tempSelected.remove(item);
|
||||
} else {
|
||||
_tempSelected.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: 20.h),
|
||||
Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'SCDream',
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
itemCount: widget.itemList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = widget.itemList[index];
|
||||
final isSelected = _tempSelected.contains(item);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
side: BorderSide(
|
||||
color: isSelected
|
||||
? const Color(0xFFFF7500)
|
||||
: Colors.grey[300]!,
|
||||
),
|
||||
),
|
||||
tileColor: isSelected
|
||||
? const Color(0xFFFF7500).withValues(
|
||||
alpha: 0.1,
|
||||
) // Fixed deprecated
|
||||
: Colors.white,
|
||||
title: Text(
|
||||
item,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontSize: 15.sp,
|
||||
color: isSelected
|
||||
? const Color(0xFFFF7500)
|
||||
: Colors.black,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: const Color(0xFFFF7500),
|
||||
size: 20.w,
|
||||
)
|
||||
: null,
|
||||
onTap: () => _onItemTap(item),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
// 기타 선택 시 입력창 표시
|
||||
if (item == "기타" && isSelected)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 10.w,
|
||||
right: 10.w,
|
||||
bottom: 20.h,
|
||||
),
|
||||
child: TextField(
|
||||
controller: _otherController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '내용을 입력해주세요',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
color: Colors.grey,
|
||||
),
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(20.w),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50.h,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
widget.onComplete(
|
||||
_tempSelected,
|
||||
_isOtherSelected ? _otherController.text : '',
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFF7500),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'선택 완료',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SCDream',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
|
||||
set(BINARY_NAME "app")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.example.app")
|
||||
set(APPLICATION_ID "com.daoblock.rup")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
|
||||
@ -5,17 +5,21 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import cloud_firestore
|
||||
import file_selector_macos
|
||||
import firebase_auth
|
||||
import firebase_core
|
||||
import firebase_storage
|
||||
import flutter_secure_storage_macos
|
||||
import google_sign_in_ios
|
||||
import video_player_avfoundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin"))
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||
|
||||
@ -385,7 +385,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.daoblock.rup.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/app";
|
||||
@ -399,7 +399,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.daoblock.rup.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/app";
|
||||
@ -413,7 +413,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.daoblock.rup.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/app";
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
PRODUCT_NAME = app
|
||||
|
||||
// The application's bundle identifier
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.app
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.daoblock.rup
|
||||
|
||||
// The copyright displayed in application information
|
||||
PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved.
|
||||
|
||||
3
app/move_file.ps1
Normal file
@ -0,0 +1,3 @@
|
||||
New-Item -ItemType Directory -Force -Path 'android/app/src/main/kotlin/com/daoblock/rup'
|
||||
Move-Item -Path 'android/app/src/main/kotlin/com/example/app/MainActivity.kt' -Destination 'android/app/src/main/kotlin/com/daoblock/rup/MainActivity.kt'
|
||||
Remove-Item -Recurse -Force 'android/app/src/main/kotlin/com/example/app'
|
||||
@ -49,6 +49,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
cloud_firestore:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cloud_firestore
|
||||
sha256: "2d33da4465bdb81b6685c41b535895065adcb16261beb398f5f3bbc623979e9c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.6.12"
|
||||
cloud_firestore_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cloud_firestore_platform_interface
|
||||
sha256: "413c4e01895cf9cb3de36fa5c219479e06cd4722876274ace5dfc9f13ab2e39b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.12"
|
||||
cloud_firestore_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cloud_firestore_web
|
||||
sha256: c1e30fc4a0fcedb08723fb4b1f12ee4e56d937cbf9deae1bda43cbb6367bb4cf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.4.12"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -217,6 +241,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.24.1"
|
||||
firebase_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_storage
|
||||
sha256: "958fc88a7ef0b103e694d30beed515c8f9472dde7e8459b029d0e32b8ff03463"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.4.10"
|
||||
firebase_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_storage_platform_interface
|
||||
sha256: d2661c05293c2a940c8ea4bc0444e1b5566c79dd3202c2271140c082c8cd8dd4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.10"
|
||||
firebase_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_storage_web
|
||||
sha256: "629a557c5e1ddb97a3666cbf225e97daa0a66335dbbfdfdce113ef9f881e833f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.10.17"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -717,6 +773,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.2"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -43,6 +43,9 @@ dependencies:
|
||||
flutter_secure_storage: ^9.0.0
|
||||
image_picker: ^1.2.1
|
||||
flutter_screenutil: ^5.9.3
|
||||
cloud_firestore: ^5.0.0
|
||||
firebase_storage: ^12.0.0
|
||||
uuid: ^4.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@ -6,18 +6,24 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <cloud_firestore/cloud_firestore_plugin_c_api.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <firebase_storage/firebase_storage_plugin_c_api.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
CloudFirestorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("CloudFirestorePluginCApi"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
FirebaseStoragePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseStoragePluginCApi"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
}
|
||||
|
||||
@ -3,9 +3,11 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
cloud_firestore
|
||||
file_selector_windows
|
||||
firebase_auth
|
||||
firebase_core
|
||||
firebase_storage
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
||||
|
||||