파이어 베이스에서 MYSql로 이동>> 이미지 등록 오류 수정, 테이블에 질병 관련 항목 추가
This commit is contained in:
parent
4059ea9af7
commit
7309b92ac6
@ -59,25 +59,60 @@ class Pet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
factory Pet.fromMap(Map<String, dynamic> map) {
|
factory Pet.fromMap(Map<String, dynamic> map) {
|
||||||
|
DateTime? parseDate(dynamic value) {
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to safely parse List<String>
|
||||||
|
List<String> parseList(dynamic list) {
|
||||||
|
if (list == null) return [];
|
||||||
|
if (list is! List) return [];
|
||||||
|
|
||||||
|
return list.map((e) {
|
||||||
|
if (e is String) return e;
|
||||||
|
if (e is Map) return e['name']?.toString() ?? e.toString();
|
||||||
|
return e.toString();
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
return Pet(
|
return Pet(
|
||||||
id: map['id'] ?? '',
|
id: map['id']?.toString() ?? '',
|
||||||
ownerId: map['ownerId'] ?? '',
|
ownerId:
|
||||||
|
map['userId']?.toString() ??
|
||||||
|
map['ownerId']?.toString() ??
|
||||||
|
'', // Backend sends userId
|
||||||
name: map['name'] ?? '',
|
name: map['name'] ?? '',
|
||||||
species: map['species'] ?? '',
|
species: map['species'] ?? '',
|
||||||
breed: map['breed'] ?? '',
|
breed: map['breed'] ?? '',
|
||||||
gender: map['gender'] ?? '',
|
gender: map['gender'] ?? '',
|
||||||
isNeutered: map['isNeutered'] ?? false,
|
isNeutered:
|
||||||
birthDate: map['birthDate'] != null
|
map['isNeutered'] == true ||
|
||||||
? (map['birthDate'] as Timestamp).toDate()
|
map['isNeutered'] == 'true', // Handle string/bool
|
||||||
: null,
|
birthDate: parseDate(map['birthDate']),
|
||||||
isDateUnknown: map['isDateUnknown'] ?? false,
|
isDateUnknown:
|
||||||
|
map['isDateUnknown'] == true || map['isDateUnknown'] == 'true',
|
||||||
registrationNumber: map['registrationNumber'],
|
registrationNumber: map['registrationNumber'],
|
||||||
profileImageUrl: map['profileImageUrl'],
|
profileImageUrl: _parseImageUrl(
|
||||||
weight: map['weight']?.toDouble(),
|
map['profileImageUrl'] ?? map['profileImagePath'],
|
||||||
diseases: List<String>.from(map['diseases'] ?? []),
|
),
|
||||||
pastDiseases: List<String>.from(map['pastDiseases'] ?? []),
|
weight: map['weight'] is int
|
||||||
healthConcerns: List<String>.from(map['healthConcerns'] ?? []),
|
? (map['weight'] as int).toDouble()
|
||||||
createdAt: (map['createdAt'] as Timestamp).toDate(),
|
: map['weight']?.toDouble(),
|
||||||
|
diseases: parseList(map['diseases']),
|
||||||
|
pastDiseases: parseList(map['pastDiseases']),
|
||||||
|
healthConcerns: parseList(map['healthConcerns']),
|
||||||
|
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String? _parseImageUrl(String? url) {
|
||||||
|
if (url == null || url.isEmpty) return null;
|
||||||
|
if (url.startsWith('http')) return url;
|
||||||
|
// Prepend Backend URL for relative paths (MySQL/Multer)
|
||||||
|
// TODO: Improve architecture to not hardcode interactions in Model
|
||||||
|
const baseUrl = 'http://10.0.2.2:3000';
|
||||||
|
return '$baseUrl$url';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -482,7 +482,7 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
// 바깥 컨테이너: 행 구분선 담당
|
// 바깥 컨테이너: 행 구분선 담당
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(color: Color(0xFFE0E0E0), width: 1),
|
top: BorderSide(color: Color(0xFFE0E0E0), width: 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|||||||
@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'pet_form_screen.dart';
|
import 'pet_form_screen.dart';
|
||||||
import '../services/firestore_service.dart';
|
import '../services/api_service.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
import '../models/pet_model.dart';
|
import '../models/pet_model.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../widgets/home/pet_profile_card.dart';
|
import '../widgets/home/pet_profile_card.dart';
|
||||||
@ -15,14 +16,47 @@ class HomeScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
class _HomeScreenState extends State<HomeScreen> {
|
||||||
final FirestoreService _firestoreService = FirestoreService();
|
final ApiService _apiService = ApiService();
|
||||||
String? _userId;
|
final AuthService _authService = AuthService();
|
||||||
|
int? _userId;
|
||||||
Pet? _selectedPet;
|
Pet? _selectedPet;
|
||||||
|
Future<List<Pet>>? _petsFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_userId = _firestoreService.getCurrentUserId();
|
_loadUserAndPets();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadUserAndPets() async {
|
||||||
|
final userInfo = await _authService.getUserInfo();
|
||||||
|
if (userInfo != null) {
|
||||||
|
setState(() {
|
||||||
|
_userId = userInfo['id'] is int
|
||||||
|
? userInfo['id']
|
||||||
|
: int.tryParse(userInfo['id'].toString());
|
||||||
|
_petsFuture = _fetchPets();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Pet>> _fetchPets() async {
|
||||||
|
if (_userId == null) return [];
|
||||||
|
try {
|
||||||
|
final petsData = await _apiService.getPets(_userId!);
|
||||||
|
return petsData.map((e) => Pet.fromMap(e)).toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading pets: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshPets() {
|
||||||
|
if (_userId != null) {
|
||||||
|
setState(() {
|
||||||
|
_petsFuture = _fetchPets();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -34,8 +68,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: StreamBuilder<List<Pet>>(
|
child: FutureBuilder<List<Pet>>(
|
||||||
stream: _firestoreService.getPets(_userId!),
|
future: _petsFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@ -67,10 +101,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const PetFormScreen(),
|
builder: (context) => const PetFormScreen(),
|
||||||
),
|
),
|
||||||
);
|
).then((value) {
|
||||||
|
if (value == true) _refreshPets();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 4.h),
|
padding: EdgeInsets.only(
|
||||||
|
top: 4.h,
|
||||||
|
bottom: 4.h,
|
||||||
|
right: 12.w,
|
||||||
|
), // Added right padding
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@ -79,7 +119,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
width: 40.w,
|
width: 40.w,
|
||||||
height: 40.h,
|
height: 40.h,
|
||||||
),
|
),
|
||||||
SizedBox(width: 10.w),
|
SizedBox(width: 6.w), // Reduced spacing
|
||||||
Text(
|
Text(
|
||||||
'반려동물 등록 +',
|
'반려동물 등록 +',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@ -153,7 +193,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const PetFormScreen(),
|
builder: (context) => const PetFormScreen(),
|
||||||
),
|
),
|
||||||
);
|
).then((value) {
|
||||||
|
if (value == true) _refreshPets();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
@ -282,7 +324,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(children: [PetProfileCard(pet: displayPet)]),
|
child: Column(
|
||||||
|
children: [
|
||||||
|
PetProfileCard(
|
||||||
|
pet: displayPet,
|
||||||
|
onPetUpdated: _refreshPets,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -162,7 +162,7 @@ class _MyInfoScreenState extends State<MyInfoScreen> {
|
|||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Color(0xFFFF7500), // 강조색
|
color: Color(0xFFFF7500),
|
||||||
fontFamily: 'SCDream',
|
fontFamily: 'SCDream',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -343,9 +343,9 @@ class _MyInfoScreenState extends State<MyInfoScreen> {
|
|||||||
Widget _buildMenuItem({
|
Widget _buildMenuItem({
|
||||||
required String title,
|
required String title,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
VoidCallback? onTap, // onTap을 nullable로 변경
|
VoidCallback? onTap,
|
||||||
bool isDestructive = false,
|
bool isDestructive = false,
|
||||||
String? trailingText, // 뒤에 텍스트를 표시할 수 있도록 추가
|
String? trailingText,
|
||||||
}) {
|
}) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -370,7 +370,7 @@ class _MyInfoScreenState extends State<MyInfoScreen> {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'SCDream',
|
fontFamily: 'SCDream',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500, // Medium
|
fontWeight: FontWeight.w500,
|
||||||
color: isDestructive ? Colors.red : Colors.black,
|
color: isDestructive ? Colors.red : Colors.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import screenutil
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
class NoticeScreen extends StatelessWidget {
|
class NoticeScreen extends StatelessWidget {
|
||||||
const NoticeScreen({super.key});
|
const NoticeScreen({super.key});
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../data/pet_data.dart';
|
import '../data/pet_data.dart';
|
||||||
import '../widgets/pet_registration/selection_modal.dart';
|
|
||||||
import '../widgets/pet_registration/input_formatters.dart';
|
|
||||||
import '../services/firestore_service.dart';
|
import '../services/firestore_service.dart';
|
||||||
import '../models/pet_model.dart';
|
import '../models/pet_model.dart';
|
||||||
|
import '../widgets/pet_registration/input_formatters.dart';
|
||||||
|
|
||||||
class PetDetailScreen extends StatefulWidget {
|
class PetDetailScreen extends StatefulWidget {
|
||||||
final Pet pet;
|
final Pet pet;
|
||||||
@ -124,6 +124,9 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
String otherText = '';
|
String otherText = '';
|
||||||
|
|
||||||
for (var item in source) {
|
for (var item in source) {
|
||||||
|
if (item.trim().isEmpty || item == '[]' || item.contains('['))
|
||||||
|
continue; // Filter out bad data
|
||||||
|
|
||||||
if (item.startsWith('기타(') && item.endsWith(')')) {
|
if (item.startsWith('기타(') && item.endsWith(')')) {
|
||||||
selected.add('기타');
|
selected.add('기타');
|
||||||
otherText = item.substring(3, item.length - 1);
|
otherText = item.substring(3, item.length - 1);
|
||||||
@ -153,10 +156,6 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
final TextEditingController _genderController = TextEditingController();
|
final TextEditingController _genderController = TextEditingController();
|
||||||
final TextEditingController _weightController = TextEditingController();
|
final TextEditingController _weightController = TextEditingController();
|
||||||
|
|
||||||
// 선택된 종 정보 (품종 선택을 위해 필요)
|
|
||||||
String? _currentMajorCategory;
|
|
||||||
String? _currentMinorCategory;
|
|
||||||
|
|
||||||
String? _selectedGender;
|
String? _selectedGender;
|
||||||
bool _isNeutered = false;
|
bool _isNeutered = false;
|
||||||
List<String> _selectedDiseases = [];
|
List<String> _selectedDiseases = [];
|
||||||
@ -482,7 +481,12 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: '직접 입력해 주세요',
|
hintText: '직접 입력해 주세요',
|
||||||
isDense: true,
|
isDense: true,
|
||||||
border: OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
|
focusedBorder: const OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: AppColors.highlight,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -546,7 +550,7 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
height: 52.h,
|
height: 52.h,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.highlight,
|
color: AppColors.highlight,
|
||||||
borderRadius: BorderRadius.circular(12.r),
|
borderRadius: BorderRadius.circular(30.r),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -656,8 +660,7 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_speciesController.text =
|
_speciesController.text =
|
||||||
speciesInputController.text;
|
speciesInputController.text;
|
||||||
_currentMajorCategory = null;
|
|
||||||
_currentMinorCategory = null;
|
|
||||||
_breedController.clear();
|
_breedController.clear();
|
||||||
});
|
});
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@ -696,8 +699,6 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
setModalState(() => showInput = true);
|
setModalState(() => showInput = true);
|
||||||
else {
|
else {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentMajorCategory = selectedMajor;
|
|
||||||
_currentMinorCategory = minor;
|
|
||||||
_speciesController.text = minor;
|
_speciesController.text = minor;
|
||||||
_breedController.clear();
|
_breedController.clear();
|
||||||
});
|
});
|
||||||
@ -940,6 +941,15 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
controller: _yearController,
|
controller: _yearController,
|
||||||
hint: 'YYYY',
|
hint: 'YYYY',
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(4),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value.length == 4) {
|
||||||
|
FocusScope.of(context).requestFocus(_monthFocus);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 10),
|
SizedBox(width: 10),
|
||||||
@ -948,6 +958,16 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
controller: _monthController,
|
controller: _monthController,
|
||||||
hint: 'MM',
|
hint: 'MM',
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(2),
|
||||||
|
DateRangeInputFormatter(min: 1, max: 12),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value.length == 2) {
|
||||||
|
FocusScope.of(context).requestFocus(_dayFocus);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 10),
|
SizedBox(width: 10),
|
||||||
@ -956,6 +976,14 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
controller: _dayController,
|
controller: _dayController,
|
||||||
hint: 'DD',
|
hint: 'DD',
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(2),
|
||||||
|
DayInputFormatter(
|
||||||
|
monthController: _monthController,
|
||||||
|
yearController: _yearController,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -987,6 +1015,10 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
controller: _registrationNumberController,
|
controller: _registrationNumberController,
|
||||||
hint: '숫자만 입력',
|
hint: '숫자만 입력',
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(15),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 24),
|
SizedBox(height: 24),
|
||||||
|
|
||||||
@ -996,6 +1028,9 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
hint: '예: 4.5',
|
hint: '예: 4.5',
|
||||||
suffix: 'kg',
|
suffix: 'kg',
|
||||||
keyboardType: TextInputType.numberWithOptions(decimal: true),
|
keyboardType: TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 24),
|
SizedBox(height: 24),
|
||||||
|
|
||||||
@ -1115,12 +1150,18 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
|
|||||||
VoidCallback? onTap,
|
VoidCallback? onTap,
|
||||||
TextInputType? keyboardType,
|
TextInputType? keyboardType,
|
||||||
String? suffix,
|
String? suffix,
|
||||||
|
List<TextInputFormatter>? inputFormatters,
|
||||||
|
FocusNode? focusNode, // Added to support focusing logic
|
||||||
|
ValueChanged<String>? onChanged, // Added to support focus change
|
||||||
}) {
|
}) {
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
readOnly: readOnly,
|
readOnly: readOnly,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
|
inputFormatters: inputFormatters, // Added
|
||||||
|
focusNode: focusNode, // Added
|
||||||
|
onChanged: onChanged, // Added
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
suffixText: suffix,
|
suffixText: suffix,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
141
app/lib/services/api_service.dart
Normal file
141
app/lib/services/api_service.dart
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class ApiService {
|
||||||
|
// Use 10.0.2.2 for Android Emulator, localhost for iOS/Web
|
||||||
|
static final String baseUrl = Platform.isAndroid
|
||||||
|
? 'http://10.0.2.2:3000'
|
||||||
|
: 'http://localhost:3000';
|
||||||
|
|
||||||
|
final Dio _dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Initial Master Data (Groups, Species, Breeds, Diseases)
|
||||||
|
Future<Map<String, dynamic>> getInitialData() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/common/initial-data');
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error fetching initial data: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register Pet
|
||||||
|
Future<Map<String, dynamic>> registerPet({
|
||||||
|
required int userId,
|
||||||
|
required String name,
|
||||||
|
required String species,
|
||||||
|
required String breed,
|
||||||
|
required String gender,
|
||||||
|
required bool isNeutered,
|
||||||
|
DateTime? birthDate,
|
||||||
|
required bool isDateUnknown,
|
||||||
|
double? weight,
|
||||||
|
String? registrationNumber,
|
||||||
|
File? profileImage,
|
||||||
|
List<String>? diseases,
|
||||||
|
List<String>? pastDiseases,
|
||||||
|
List<String>? healthConcerns,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'userId': userId,
|
||||||
|
'name': name,
|
||||||
|
'species': species,
|
||||||
|
'breed': breed,
|
||||||
|
'gender': gender, // "남아", "여아", "기타"
|
||||||
|
'isNeutered': isNeutered,
|
||||||
|
'birthDate': birthDate?.toIso8601String(),
|
||||||
|
'isDateUnknown': isDateUnknown,
|
||||||
|
'weight': weight,
|
||||||
|
'registrationNumber': registrationNumber,
|
||||||
|
'diseases': diseases != null ? jsonEncode(diseases) : '[]',
|
||||||
|
'pastDiseases': pastDiseases != null ? jsonEncode(pastDiseases) : '[]',
|
||||||
|
'healthConcerns': healthConcerns != null
|
||||||
|
? jsonEncode(healthConcerns)
|
||||||
|
: '[]',
|
||||||
|
if (profileImage != null)
|
||||||
|
'profileImage': await MultipartFile.fromFile(
|
||||||
|
profileImage.path,
|
||||||
|
filename: profileImage.path.split('/').last,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await _dio.post('/pets', data: formData);
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error registering pet: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Pet
|
||||||
|
Future<Map<String, dynamic>> updatePet({
|
||||||
|
required int petId,
|
||||||
|
required String name,
|
||||||
|
required String species,
|
||||||
|
required String breed,
|
||||||
|
required String gender,
|
||||||
|
required bool isNeutered,
|
||||||
|
DateTime? birthDate,
|
||||||
|
required bool isDateUnknown,
|
||||||
|
double? weight,
|
||||||
|
String? registrationNumber,
|
||||||
|
File? profileImage,
|
||||||
|
List<String>? diseases,
|
||||||
|
List<String>? pastDiseases,
|
||||||
|
List<String>? healthConcerns,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'name': name,
|
||||||
|
'species': species,
|
||||||
|
'breed': breed,
|
||||||
|
'gender': gender,
|
||||||
|
'isNeutered': isNeutered,
|
||||||
|
'birthDate': birthDate?.toIso8601String(),
|
||||||
|
'isDateUnknown': isDateUnknown,
|
||||||
|
'weight': weight,
|
||||||
|
'registrationNumber': registrationNumber,
|
||||||
|
'diseases': diseases != null ? jsonEncode(diseases) : '[]',
|
||||||
|
'pastDiseases': pastDiseases != null ? jsonEncode(pastDiseases) : '[]',
|
||||||
|
'healthConcerns': healthConcerns != null
|
||||||
|
? jsonEncode(healthConcerns)
|
||||||
|
: '[]',
|
||||||
|
if (profileImage != null)
|
||||||
|
'profileImage': await MultipartFile.fromFile(
|
||||||
|
profileImage.path,
|
||||||
|
filename: profileImage.path.split('/').last,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await _dio.put('/pets/$petId', data: formData);
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error updating pet: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Pets
|
||||||
|
Future<List<dynamic>> getPets(int userId) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/pets',
|
||||||
|
queryParameters: {'userId': userId},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error fetching pets: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,25 +8,26 @@ import '../../screens/pet_form_screen.dart';
|
|||||||
|
|
||||||
class PetProfileCard extends StatelessWidget {
|
class PetProfileCard extends StatelessWidget {
|
||||||
final Pet pet;
|
final Pet pet;
|
||||||
|
final VoidCallback? onPetUpdated;
|
||||||
|
|
||||||
const PetProfileCard({super.key, required this.pet});
|
const PetProfileCard({super.key, required this.pet, this.onPetUpdated});
|
||||||
|
|
||||||
// 나이 계산 (만 나이 & 사람 나이 환산 - 단순 예시)
|
// 나이 계산 (만 나이 & 사람 나이 환산 - 단순 예시)
|
||||||
String _calculateAge(DateTime? birthDate) {
|
String _calculateAge(DateTime? birthDate) {
|
||||||
if (birthDate == null) return '알 수 없음';
|
if (birthDate == null) return '??살';
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
int age = now.year - birthDate.year;
|
int age = now.year - birthDate.year;
|
||||||
if (now.month < birthDate.month ||
|
if (now.month < birthDate.month ||
|
||||||
(now.month == birthDate.month && now.day < birthDate.day)) {
|
(now.month == birthDate.month && now.day < birthDate.day)) {
|
||||||
age--;
|
age--;
|
||||||
}
|
}
|
||||||
return '$age세';
|
return '$age살';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사람 나이 환산 (강아지 기준 대략적 계산 - 소형견 기준 예시)
|
// 사람 나이 환산 (강아지 기준 대략적 계산 - 소형견 기준 예시)
|
||||||
// 사람 나이 환산 (종별 계산법 적용)
|
// 사람 나이 환산 (종별 계산법 적용)
|
||||||
String _calculateHumanAge(DateTime? birthDate, String species) {
|
String _calculateHumanAge(DateTime? birthDate, String species) {
|
||||||
if (birthDate == null) return '??세';
|
if (birthDate == null) return '??살';
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
int ageYears = now.year - birthDate.year;
|
int ageYears = now.year - birthDate.year;
|
||||||
@ -117,7 +118,7 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '$humanAge세';
|
return '$humanAge살';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -146,7 +147,6 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
// 왼쪽: 프로필 이미지
|
// 왼쪽: 프로필 이미지
|
||||||
Container(
|
Container(
|
||||||
width: 120.w,
|
width: 120.w,
|
||||||
// height 제거 (stretch 되도록)
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16.r),
|
borderRadius: BorderRadius.circular(16.r),
|
||||||
color: Colors.grey[200],
|
color: Colors.grey[200],
|
||||||
@ -176,7 +176,6 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// 이름 & 종
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@ -208,8 +207,6 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 상세 프로필 이동 (우측 상단)
|
|
||||||
// 상세 프로필 이동 (우측 상단)
|
|
||||||
Material(
|
Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@ -221,7 +218,11 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
PetFormScreen(petToEdit: pet),
|
PetFormScreen(petToEdit: pet),
|
||||||
),
|
),
|
||||||
);
|
).then((value) {
|
||||||
|
if (value == true && onPetUpdated != null) {
|
||||||
|
onPetUpdated!();
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(8.w),
|
padding: EdgeInsets.all(8.w),
|
||||||
@ -244,7 +245,7 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 8.h), // 간격 줄임 (12 -> 8)
|
SizedBox(height: 8.h),
|
||||||
// 정보 박스 (생일, 체중)
|
// 정보 박스 (생일, 체중)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@ -261,12 +262,13 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 8.h), // 간격 줄임 (12 -> 8)
|
SizedBox(height: 8.h),
|
||||||
// 나이 정보
|
// 나이 정보
|
||||||
FittedBox(
|
FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'나이 ',
|
'나이 ',
|
||||||
@ -274,6 +276,7 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
fontFamily: 'SCDream',
|
fontFamily: 'SCDream',
|
||||||
fontSize: 14.sp,
|
fontSize: 14.sp,
|
||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
|
height: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
@ -283,13 +286,18 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
fontSize: 15.sp,
|
fontSize: 15.sp,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
|
height: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8.w),
|
padding: EdgeInsets.symmetric(horizontal: 8.w),
|
||||||
child: Text(
|
child: Text(
|
||||||
'/',
|
'/',
|
||||||
style: TextStyle(color: Colors.grey[300]),
|
style: TextStyle(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
fontSize: 14.sp,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
@ -298,14 +306,17 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
fontFamily: 'SCDream',
|
fontFamily: 'SCDream',
|
||||||
fontSize: 14.sp,
|
fontSize: 14.sp,
|
||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
|
height: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'사람 나이 환산 ${_calculateHumanAge(pet.birthDate, pet.species)}',
|
_calculateHumanAge(pet.birthDate, pet.species),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'SCDream',
|
fontFamily: 'SCDream',
|
||||||
fontSize: 12.sp,
|
fontSize: 15.sp,
|
||||||
color: Colors.white,
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
height: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -323,7 +334,7 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
fontFamily: 'SCDream',
|
fontFamily: 'SCDream',
|
||||||
fontSize: 14.sp,
|
fontSize: 14.sp,
|
||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
height: 1.2, // 줄간격 살짝 조정
|
height: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -365,7 +376,7 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.all(8.w),
|
padding: EdgeInsets.all(8.w),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.4), // 반투명 흰색 배경
|
color: Colors.white.withOpacity(0.4),
|
||||||
border: Border.all(color: const Color(0xFFEEEEEE), width: 1),
|
border: Border.all(color: const Color(0xFFEEEEEE), width: 1),
|
||||||
borderRadius: BorderRadius.circular(12.r),
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
),
|
),
|
||||||
@ -377,7 +388,7 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'SCDream',
|
fontFamily: 'SCDream',
|
||||||
fontSize: 12.sp,
|
fontSize: 12.sp,
|
||||||
color: Colors.grey[600], // 가독성을 위해 조금 진하게
|
color: Colors.grey[600],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4.h),
|
SizedBox(height: 4.h),
|
||||||
|
|||||||
@ -20,16 +20,13 @@ class DateRangeInputFormatter extends TextInputFormatter {
|
|||||||
final int? value = int.tryParse(newValue.text);
|
final int? value = int.tryParse(newValue.text);
|
||||||
|
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return oldValue; // 숫자가 아닌 경우 이전 값 유지
|
return oldValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value < min || value > max) {
|
if (value < min || value > max) {
|
||||||
// 범위를 벗어나면 이전 값 유지 (단, 입력 중인 상태 고려 - 예: 3을 입력하려는데 30이 되면 안됨)
|
|
||||||
// 여기서는 단순하게 max보다 크면 입력 불가 처리 (사용자 경험상 이게 나을 수 있음)
|
|
||||||
if (newValue.text.length > max.toString().length) {
|
if (newValue.text.length > max.toString().length) {
|
||||||
return oldValue;
|
return oldValue;
|
||||||
}
|
}
|
||||||
// 더 정교한 로직이 필요할 수 있으나, 기본적으로 입력 막음
|
|
||||||
if (value > max) return oldValue;
|
if (value > max) return oldValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
const { connectDB, sequelize } = require('./config/db');
|
const { connectDB, sequelize } = require('./config/db');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
|
const commonRoutes = require('./routes/common');
|
||||||
|
const petRoutes = require('./routes/pets');
|
||||||
|
const seedData = require('./scripts/seedData');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 3000;
|
const port = 3000;
|
||||||
@ -9,9 +13,15 @@ const port = 3000;
|
|||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json()); // Body parser for JSON
|
app.use(express.json()); // Body parser for JSON
|
||||||
|
app.use(express.urlencoded({ extended: true })); // For multipart form-data handling if needed (though multer handles it)
|
||||||
|
|
||||||
|
// Static Files
|
||||||
|
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
|
app.use('/common', commonRoutes);
|
||||||
|
app.use('/pets', petRoutes);
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.send('Hello from Express Backend!');
|
res.send('Hello from Express Backend!');
|
||||||
@ -21,11 +31,14 @@ app.get('/', (req, res) => {
|
|||||||
const startServer = async () => {
|
const startServer = async () => {
|
||||||
await connectDB();
|
await connectDB();
|
||||||
|
|
||||||
// Sync models (in production, use migration instead of sync({alter: true}))
|
// Sync models
|
||||||
// For dev: force: false to keep data, alter: true to update schema
|
// force: false to keep data, alter: true to update schema
|
||||||
await sequelize.sync({ alter: true });
|
await sequelize.sync({ alter: true });
|
||||||
console.log('Database synced');
|
console.log('Database synced');
|
||||||
|
|
||||||
|
// Seed Data
|
||||||
|
await seedData();
|
||||||
|
|
||||||
app.listen(port, '0.0.0.0', () => {
|
app.listen(port, '0.0.0.0', () => {
|
||||||
console.log(`Backend app listening on port ${port}`);
|
console.log(`Backend app listening on port ${port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
26
backend/manual_sync.js
Normal file
26
backend/manual_sync.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const { sequelize } = require('./config/db');
|
||||||
|
require('./models/Pet'); // Load models
|
||||||
|
|
||||||
|
const syncDB = async () => {
|
||||||
|
try {
|
||||||
|
console.log('Connecting to DB...');
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('Connected. Syncing schema...');
|
||||||
|
|
||||||
|
// Force alter to ensure columns are added
|
||||||
|
await sequelize.sync({ alter: true });
|
||||||
|
|
||||||
|
console.log('Schema update complete. Checking Pet table columns...');
|
||||||
|
|
||||||
|
// Check columns
|
||||||
|
const [results] = await sequelize.query('DESCRIBE Pets;');
|
||||||
|
console.log('Table Structure:', results.map(r => r.Field));
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sync failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncDB();
|
||||||
78
backend/models/Pet.js
Normal file
78
backend/models/Pet.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/db');
|
||||||
|
|
||||||
|
const Pet = sequelize.define('Pet', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
autoIncrement: true,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Reference to User table',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
species: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Species Name (e.g. 강아지)',
|
||||||
|
},
|
||||||
|
breed: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Breed Name (e.g. 말티즈)',
|
||||||
|
},
|
||||||
|
gender: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '남아, 여아, 기타',
|
||||||
|
},
|
||||||
|
isNeutered: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
birthDate: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
isDateUnknown: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
weight: {
|
||||||
|
type: DataTypes.FLOAT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
registrationNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
profileImagePath: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Relative path to uploaded image',
|
||||||
|
},
|
||||||
|
diseases: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'JSON string of current diseases',
|
||||||
|
},
|
||||||
|
pastDiseases: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'JSON string of past diseases',
|
||||||
|
},
|
||||||
|
healthConcerns: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'JSON string of health concerns',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
timestamps: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = Pet;
|
||||||
24
backend/models/PetBreed.js
Normal file
24
backend/models/PetBreed.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/db');
|
||||||
|
|
||||||
|
const PetBreed = sequelize.define('PetBreed', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
autoIncrement: true,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
speciesId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Reference to PetSpecies',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'e.g., 말티즈, 푸들',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
timestamps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = PetBreed;
|
||||||
20
backend/models/PetDisease.js
Normal file
20
backend/models/PetDisease.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/db');
|
||||||
|
|
||||||
|
const PetDisease = sequelize.define('PetDisease', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
autoIncrement: true,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
comment: 'e.g., 피부질환, 눈 질환',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
timestamps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = PetDisease;
|
||||||
20
backend/models/PetGroup.js
Normal file
20
backend/models/PetGroup.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/db');
|
||||||
|
|
||||||
|
const PetGroup = sequelize.define('PetGroup', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
autoIncrement: true,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
comment: 'e.g., 포유류, 파충류',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
timestamps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = PetGroup;
|
||||||
24
backend/models/PetSpecies.js
Normal file
24
backend/models/PetSpecies.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/db');
|
||||||
|
|
||||||
|
const PetSpecies = sequelize.define('PetSpecies', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
autoIncrement: true,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
groupId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Reference to PetGroup',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'e.g., 강아지, 고양이',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
timestamps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = PetSpecies;
|
||||||
32
backend/models/index.js
Normal file
32
backend/models/index.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const { sequelize } = require('../config/db');
|
||||||
|
|
||||||
|
const User = require('./user');
|
||||||
|
const PetGroup = require('./PetGroup');
|
||||||
|
const PetSpecies = require('./PetSpecies');
|
||||||
|
const PetBreed = require('./PetBreed');
|
||||||
|
const PetDisease = require('./PetDisease');
|
||||||
|
const Pet = require('./Pet');
|
||||||
|
|
||||||
|
// Associations
|
||||||
|
|
||||||
|
// Master Data Relationships
|
||||||
|
PetGroup.hasMany(PetSpecies, { foreignKey: 'groupId' });
|
||||||
|
PetSpecies.belongsTo(PetGroup, { foreignKey: 'groupId' });
|
||||||
|
|
||||||
|
PetSpecies.hasMany(PetBreed, { foreignKey: 'speciesId' });
|
||||||
|
PetBreed.belongsTo(PetSpecies, { foreignKey: 'speciesId' });
|
||||||
|
|
||||||
|
// User <-> Pet
|
||||||
|
User.hasMany(Pet, { foreignKey: 'userId' });
|
||||||
|
Pet.belongsTo(User, { foreignKey: 'userId' });
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sequelize,
|
||||||
|
User,
|
||||||
|
PetGroup,
|
||||||
|
PetSpecies,
|
||||||
|
PetBreed,
|
||||||
|
PetDisease,
|
||||||
|
Pet,
|
||||||
|
};
|
||||||
@ -13,6 +13,7 @@
|
|||||||
"google-auth-library": "^9.4.1",
|
"google-auth-library": "^9.4.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mysql2": "^3.6.5",
|
"mysql2": "^3.6.5",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
"sequelize": "^6.35.1"
|
"sequelize": "^6.35.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
30
backend/routes/common.js
Normal file
30
backend/routes/common.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { PetGroup, PetSpecies, PetBreed, PetDisease } = require('../models');
|
||||||
|
|
||||||
|
// GET /common/initial-data
|
||||||
|
// Returns hierarchical master data for frontend caching
|
||||||
|
router.get('/initial-data', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const groups = await PetGroup.findAll({
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: PetSpecies,
|
||||||
|
include: [{ model: PetBreed }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const diseases = await PetDisease.findAll();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
groups,
|
||||||
|
diseases,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching initial data:', error);
|
||||||
|
res.status(500).json({ message: 'Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
140
backend/routes/pets.js
Normal file
140
backend/routes/pets.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { Pet, User } = require('../models');
|
||||||
|
|
||||||
|
// Multer Setup
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
// Use absolute path to ensure it works regardless of CWD
|
||||||
|
const uploadPath = path.join(__dirname, '../uploads/pets/');
|
||||||
|
if (!fs.existsSync(uploadPath)) {
|
||||||
|
fs.mkdirSync(uploadPath, { recursive: true });
|
||||||
|
}
|
||||||
|
cb(null, uploadPath);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
cb(null, uniqueSuffix + path.extname(file.originalname));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({ storage: storage });
|
||||||
|
|
||||||
|
// POST /pets - Create a new pet with optional image
|
||||||
|
router.post('/', upload.single('profileImage'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
userId, name, species, breed, gender, isNeutered,
|
||||||
|
birthDate, isDateUnknown, weight, registrationNumber,
|
||||||
|
diseases, pastDiseases, healthConcerns
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const profileImagePath = req.file ? `/uploads/pets/${req.file.filename}` : null;
|
||||||
|
|
||||||
|
const newPet = await Pet.create({
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
species,
|
||||||
|
breed,
|
||||||
|
gender,
|
||||||
|
isNeutered: isNeutered === 'true', // Multipart sends strings
|
||||||
|
birthDate: birthDate ? new Date(birthDate) : null,
|
||||||
|
isDateUnknown: isDateUnknown === 'true',
|
||||||
|
weight: weight ? parseFloat(weight) : null,
|
||||||
|
registrationNumber,
|
||||||
|
profileImagePath,
|
||||||
|
diseases: diseases ? diseases : '[]',
|
||||||
|
pastDiseases: pastDiseases ? pastDiseases : '[]',
|
||||||
|
healthConcerns: healthConcerns ? healthConcerns : '[]',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(newPet);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating pet:', error);
|
||||||
|
res.status(500).json({ message: error.message, error: error.toString() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /pets - Get pets for a specific user (query param ?userId=...)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.query;
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(400).json({ message: 'Missing userId query parameter' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pets = await Pet.findAll({
|
||||||
|
where: { userId },
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse JSON strings back to arrays for the response
|
||||||
|
const parsedPets = pets.map(pet => {
|
||||||
|
const p = pet.toJSON();
|
||||||
|
try { p.diseases = p.diseases ? JSON.parse(p.diseases) : []; } catch (e) { p.diseases = []; }
|
||||||
|
try { p.pastDiseases = p.pastDiseases ? JSON.parse(p.pastDiseases) : []; } catch (e) { p.pastDiseases = []; }
|
||||||
|
try { p.healthConcerns = p.healthConcerns ? JSON.parse(p.healthConcerns) : []; } catch (e) { p.healthConcerns = []; }
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(parsedPets);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching pets:', error);
|
||||||
|
res.status(500).json({ message: 'Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /pets/:id - Update an existing pet
|
||||||
|
router.put('/:id', upload.single('profileImage'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const {
|
||||||
|
name, species, breed, gender, isNeutered,
|
||||||
|
birthDate, isDateUnknown, weight, registrationNumber,
|
||||||
|
diseases, pastDiseases, healthConcerns
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const pet = await Pet.findByPk(id);
|
||||||
|
if (!pet) {
|
||||||
|
return res.status(404).json({ message: 'Pet not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update fields if they are provided
|
||||||
|
if (name) pet.name = name;
|
||||||
|
if (species) pet.species = species;
|
||||||
|
if (breed) pet.breed = breed;
|
||||||
|
if (gender) pet.gender = gender;
|
||||||
|
if (isNeutered !== undefined) pet.isNeutered = isNeutered === 'true';
|
||||||
|
if (birthDate !== undefined) pet.birthDate = birthDate ? new Date(birthDate) : null;
|
||||||
|
if (isDateUnknown !== undefined) pet.isDateUnknown = isDateUnknown === 'true';
|
||||||
|
if (weight !== undefined) pet.weight = weight ? parseFloat(weight) : null;
|
||||||
|
if (registrationNumber !== undefined) pet.registrationNumber = registrationNumber;
|
||||||
|
|
||||||
|
if (diseases !== undefined) pet.diseases = diseases ? diseases : '[]';
|
||||||
|
if (pastDiseases !== undefined) pet.pastDiseases = pastDiseases ? pastDiseases : '[]';
|
||||||
|
if (healthConcerns !== undefined) pet.healthConcerns = healthConcerns ? healthConcerns : '[]';
|
||||||
|
|
||||||
|
if (req.file) {
|
||||||
|
pet.profileImagePath = `/uploads/pets/${req.file.filename}`;
|
||||||
|
// TODO: Delete old image file if exists?
|
||||||
|
}
|
||||||
|
|
||||||
|
await pet.save();
|
||||||
|
|
||||||
|
// Parse for response
|
||||||
|
const p = pet.toJSON();
|
||||||
|
try { p.diseases = p.diseases ? JSON.parse(p.diseases) : []; } catch (e) { p.diseases = []; }
|
||||||
|
try { p.pastDiseases = p.pastDiseases ? JSON.parse(p.pastDiseases) : []; } catch (e) { p.pastDiseases = []; }
|
||||||
|
try { p.healthConcerns = p.healthConcerns ? JSON.parse(p.healthConcerns) : []; } catch (e) { p.healthConcerns = []; }
|
||||||
|
|
||||||
|
res.json(p);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating pet:', error);
|
||||||
|
res.status(500).json({ message: error.message, error: error.toString() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
88
backend/scripts/seedData.js
Normal file
88
backend/scripts/seedData.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
const { PetGroup, PetSpecies, PetBreed, PetDisease, sequelize } = require('../models');
|
||||||
|
|
||||||
|
const seedData = async () => {
|
||||||
|
try {
|
||||||
|
const groupCount = await PetGroup.count();
|
||||||
|
if (groupCount > 0) {
|
||||||
|
console.log('Master data already exists. Skipping seed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seeding Master Data...');
|
||||||
|
|
||||||
|
// Data from pet_data.dart
|
||||||
|
const diseaseList = [
|
||||||
|
"피부질환", "눈 질환", "치아 / 구강 질환", "뼈 / 관절 질환", "생식기 / 비뇨기 질환",
|
||||||
|
"심장 / 혈관 질환", "소화기 질환", "호흡기 질환", "내분비계 질환", "뇌신경 질환",
|
||||||
|
"생식기 질환", "귀 질환", "코 질환", "기타"
|
||||||
|
];
|
||||||
|
|
||||||
|
const breedsData = {
|
||||||
|
"포유류": {
|
||||||
|
"강아지": ["말티즈", "푸들", "포메라니안", "믹스견", "치와와", "시츄", "비숑 프리제", "골든 리트리버", "진돗개", "웰시 코기", "프렌치 불독", "시바견", "닥스후트", "요크셔 테리어", "보더 콜리", "사모예드", "허스키", "말라뮤트", "기타(직접 입력)"],
|
||||||
|
"고양이": ["코리안 숏헤어", "브리티시 숏헤어", "아메리칸 숏헤어", "뱅갈", "메인쿤", "데본 렉스", "페르시안", "러시안 블루", "샴", "렉돌", "스코티시 폴드", "먼치킨", "노르웨이 숲", "믹스묘", "기타(직접 입력)"],
|
||||||
|
"햄스터": ["정글리안", "펄", "푸딩", "골든 햄스터", "로보로브스키", "기타(직접 입력)"],
|
||||||
|
"토끼": ["롭이어", "더치", "라이언 헤드", "드워프", "렉스", "기타(직접 입력)"],
|
||||||
|
"기니피그": ["잉글리쉬", "아비시니안", "페루비안", "실키", "기타(직접 입력)"],
|
||||||
|
"고슴도치": ["플라티나", "화이트 초코", "알비노", "핀토", "기타(직접 입력)"],
|
||||||
|
"기타(직접 입력)": ["기타(직접 입력)"]
|
||||||
|
},
|
||||||
|
"파충류": {
|
||||||
|
"거북이": ["커먼 머스크 터틀", "레이저백", "육지거북", "붉은귀거북", "남생이", "기타(직접 입력)"],
|
||||||
|
"도마뱀": ["크레스티드 게코", "리키에너스 게코", "가고일 게코", "레오파드 게코", "비어디 드래곤", "블루텅 스킨크", "이구아나", "기타(직접 입력)"],
|
||||||
|
"뱀": ["볼 파이톤", "가터 스네이크", "호그노즈 스네이크", "콘 스네이크", "킹 스네이크", "밀크 스네이크", "기타(직접 입력)"],
|
||||||
|
"기타(직접 입력)": ["기타(직접 입력)"]
|
||||||
|
},
|
||||||
|
"조류": {
|
||||||
|
"앵무새(소/중형)": ["사랑앵무(잉꼬)", "코카티엘(왕관앵무)", "모란앵무", "코뉴어", "퀘이커", "카카리키", "사자나미(빗금앵무)", "유리앵무", "기타(직접 입력)"],
|
||||||
|
"앵무새(대형)": ["뉴기니아", "회색앵무", "금강앵무(마카우)", "유황앵무(코카투)", "아마존앵무", "대본영", "기타(직접 입력)"],
|
||||||
|
"핀치/관상조": ["카나리아", "십자매", "문조", "금화조", "호금조", "백문조", "기타(직접 입력)"],
|
||||||
|
"비둘기/닭/메추리": ["애완용 비둘기", "관상닭(실키 등)", "메추리", "미니메추리", "오리/거위", "기타(직접 입력)"],
|
||||||
|
"기타(직접 입력)": ["직접 입력"]
|
||||||
|
},
|
||||||
|
"양서류": {
|
||||||
|
"개구리": ["청개구리", "팩맨", "다트 프록", "화이트 트리 프록", "기타(직접 입력)"],
|
||||||
|
"도룡뇽": ["우파루파", "파이어 벨리 뉴트", "타이거 살라만더", "기타(직접 입력)"],
|
||||||
|
"기타(직접 입력)": ["기타(직접 입력)"]
|
||||||
|
},
|
||||||
|
"어류": {
|
||||||
|
"열대어": ["구피", "베타", "테트라", "디스커스", "엔젤피쉬", "기타(직접 입력)"],
|
||||||
|
"금붕어/잉어": ["금붕어", "비단잉어", "기타(직접 입력)"],
|
||||||
|
"해수어": ["크라운피쉬(니모)", "블루탱", "기타(직접 입력)"],
|
||||||
|
"기타(직접 입력)": ["기타(직접 입력)"]
|
||||||
|
},
|
||||||
|
"기타(직접 입력)": {
|
||||||
|
"기타(직접 입력)": ["기타(직접 입력)"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Seed Diseases
|
||||||
|
for (const d of diseaseList) {
|
||||||
|
await PetDisease.findOrCreate({ where: { name: d } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Seed Group -> Species -> Breeds
|
||||||
|
for (const [groupName, speciesMap] of Object.entries(breedsData)) {
|
||||||
|
const [group] = await PetGroup.findOrCreate({ where: { name: groupName } });
|
||||||
|
|
||||||
|
for (const [speciesName, breedList] of Object.entries(speciesMap)) {
|
||||||
|
const [species] = await PetSpecies.findOrCreate({
|
||||||
|
where: { name: speciesName, groupId: group.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const breedName of breedList) {
|
||||||
|
await PetBreed.findOrCreate({
|
||||||
|
where: { name: breedName, speciesId: species.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Master Data Seeded Successfully.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Seeding Failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = seedData;
|
||||||
Loading…
x
Reference in New Issue
Block a user