diff --git a/app/.metadata b/app/.metadata index 6650897..00b74be 100644 --- a/app/.metadata +++ b/app/.metadata @@ -18,21 +18,6 @@ migration: - platform: android create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - - platform: ios - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - - platform: linux - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - - platform: macos - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - - platform: web - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - - platform: windows - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 # User provided section @@ -41,5 +26,5 @@ migration: # # Files that are not part of the templates will be ignored by default. unmanaged_files: - - "lib/main.dart" - - "ios/Runner.xcodeproj/project.pbxproj" + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/app/android/app/src/main/kotlin/com/daoblock/app/MainActivity.kt b/app/android/app/src/main/kotlin/com/daoblock/app/MainActivity.kt new file mode 100644 index 0000000..a53509f --- /dev/null +++ b/app/android/app/src/main/kotlin/com/daoblock/app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.daoblock.app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/app/assets/icons/add_icon.svg b/app/assets/icons/add_icon.svg new file mode 100644 index 0000000..4ebd105 --- /dev/null +++ b/app/assets/icons/add_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/appointment_icon.svg b/app/assets/icons/appointment_icon.svg deleted file mode 100644 index eaef2b8..0000000 --- a/app/assets/icons/appointment_icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/assets/icons/card_icon.svg b/app/assets/icons/card_icon.svg new file mode 100644 index 0000000..95c4858 --- /dev/null +++ b/app/assets/icons/card_icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/assets/icons/dashboard_icon.svg b/app/assets/icons/dashboard_icon.svg new file mode 100644 index 0000000..8b4141d --- /dev/null +++ b/app/assets/icons/dashboard_icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/assets/icons/female_icon.svg b/app/assets/icons/female_icon.svg new file mode 100644 index 0000000..35eda51 --- /dev/null +++ b/app/assets/icons/female_icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/assets/icons/home_icon.svg b/app/assets/icons/home_icon.svg index fc6de4c..46ea901 100644 --- a/app/assets/icons/home_icon.svg +++ b/app/assets/icons/home_icon.svg @@ -1,3 +1,11 @@ - - + + + + + + + + + + diff --git a/app/assets/icons/male_icon.svg b/app/assets/icons/male_icon.svg new file mode 100644 index 0000000..f1ecdd3 --- /dev/null +++ b/app/assets/icons/male_icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/assets/icons/my_icon.svg b/app/assets/icons/my_icon.svg index 2253fb4..124e5ba 100644 --- a/app/assets/icons/my_icon.svg +++ b/app/assets/icons/my_icon.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/app/assets/icons/stamp_icon.svg b/app/assets/icons/stamp_icon.svg new file mode 100644 index 0000000..f759629 --- /dev/null +++ b/app/assets/icons/stamp_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/img/bad.png b/app/assets/img/bad.png new file mode 100644 index 0000000..a89bbe8 Binary files /dev/null and b/app/assets/img/bad.png differ diff --git a/app/assets/img/badicon.png b/app/assets/img/badicon.png deleted file mode 100644 index 3b7bfcd..0000000 Binary files a/app/assets/img/badicon.png and /dev/null differ diff --git a/app/assets/img/good.png b/app/assets/img/good.png new file mode 100644 index 0000000..30f014c Binary files /dev/null and b/app/assets/img/good.png differ diff --git a/app/assets/img/goodicon.png b/app/assets/img/goodicon.png deleted file mode 100644 index 6aca8e1..0000000 Binary files a/app/assets/img/goodicon.png and /dev/null differ diff --git a/app/lib/main.dart b/app/lib/main.dart index aacc0e1..2ac53cc 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,18 +1,21 @@ -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'package:intl/date_symbol_data_local.dart'; // Import here +import 'package:intl/date_symbol_data_local.dart'; import 'dart:developer'; +import 'package:provider/provider.dart'; + import 'screens/splash_screen.dart'; import 'screens/register_complete_screen.dart'; import 'utils/log_manager.dart'; +import 'providers/pet_provider.dart'; final GlobalKey navigatorKey = GlobalKey(); void main() async { WidgetsFlutterBinding.ensureInitialized(); - await initializeDateFormatting('ko_KR', null); // Add this line + await initializeDateFormatting('ko_KR', null); // 글로벌 에러 핸들링 FlutterError.onError = (FlutterErrorDetails details) { @@ -31,7 +34,13 @@ void main() async { log('Firebase initialization failed: $e'); LogManager().addLog('[Firebase Init Error] $e'); } - runApp(const RupApp()); + + runApp( + MultiProvider( + providers: [ChangeNotifierProvider(create: (_) => PetProvider())], + child: const RupApp(), + ), + ); } class RupApp extends StatelessWidget { diff --git a/app/lib/models/schedule_model.dart b/app/lib/models/schedule_model.dart new file mode 100644 index 0000000..da93662 --- /dev/null +++ b/app/lib/models/schedule_model.dart @@ -0,0 +1,71 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +class Schedule { + final String id; + final String petId; + final DateTime date; + final String type; // 'general', 'important' + final bool isCompleted; + final String title; + final String? note; + final DateTime createdAt; + final int? repeatInterval; // 반복 간격 (1, 2, ...) + final String? repeatUnit; // 반복 단위 ('day', 'week', 'month', 'year') + final bool isAlarmOn; // 알림 설정 여부 + final DateTime? alarmTime; // 알림 시간 + + Schedule({ + required this.id, + required this.petId, + required this.date, + required this.type, + required this.isCompleted, + required this.title, + this.note, + required this.createdAt, + this.repeatInterval, + this.repeatUnit, + this.isAlarmOn = false, + this.alarmTime, + }); + + Map toMap() { + return { + 'id': id, + 'petId': petId, + 'date': Timestamp.fromDate(date), + 'type': type, + 'isCompleted': isCompleted, + 'title': title, + 'note': note, + 'createdAt': Timestamp.fromDate(createdAt), + 'repeatInterval': repeatInterval, + 'repeatUnit': repeatUnit, + 'isAlarmOn': isAlarmOn, + 'alarmTime': alarmTime != null ? Timestamp.fromDate(alarmTime!) : null, + }; + } + + factory Schedule.fromMap(Map map) { + DateTime parseDate(dynamic value) { + if (value is Timestamp) return value.toDate(); + if (value is String) return DateTime.tryParse(value) ?? DateTime.now(); + return DateTime.now(); + } + + return Schedule( + id: map['id']?.toString() ?? '', + petId: map['petId']?.toString() ?? '', + date: parseDate(map['date']), + type: map['type']?.toString() ?? 'general', + isCompleted: map['isCompleted'] == true, + title: map['title']?.toString() ?? '', + note: map['note']?.toString(), + createdAt: parseDate(map['createdAt']), + repeatInterval: map['repeatInterval'] as int?, + repeatUnit: map['repeatUnit']?.toString(), + isAlarmOn: map['isAlarmOn'] == true, + alarmTime: map['alarmTime'] != null ? parseDate(map['alarmTime']) : null, + ); + } +} diff --git a/app/lib/providers/pet_provider.dart b/app/lib/providers/pet_provider.dart new file mode 100644 index 0000000..6d2b7a6 --- /dev/null +++ b/app/lib/providers/pet_provider.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import '../models/pet_model.dart'; +import '../services/api_service.dart'; +import '../services/auth_service.dart'; + +class PetProvider with ChangeNotifier { + final ApiService _apiService = ApiService(); + final AuthService _authService = AuthService(); + + List _pets = []; + Pet? _selectedPet; + bool _isLoading = false; + int? _userId; + + List get pets => _pets; + Pet? get selectedPet => _selectedPet; + bool get isLoading => _isLoading; + int? get userId => _userId; + + // Load user and then pets + Future loadUserAndPets() async { + _isLoading = true; + // notifyListeners(); // Avoid notifying during build if called from init + + try { + final userInfo = await _authService.getUserInfo(); + if (userInfo != null) { + _userId = userInfo['id'] is int + ? userInfo['id'] + : int.tryParse(userInfo['id'].toString()); + + if (_userId != null) { + await _fetchPets(); + } + } + } catch (e) { + debugPrint('Error loading user/pets in provider: $e'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Fetch pets from API + Future _fetchPets() async { + if (_userId == null) return; + try { + final petsData = await _apiService.getPets(_userId!); + _pets = petsData.map((e) => Pet.fromMap(e)).toList(); + + // Set default selected pet if needed + if (_pets.isNotEmpty) { + if (_selectedPet == null) { + _selectedPet = _pets.first; + } else { + // Verify if currently selected pet still exists (e.g. after update) + final exists = _pets.any((p) => p.id == _selectedPet!.id); + if (exists) { + // Update the object to get latest data + _selectedPet = _pets.firstWhere((p) => p.id == _selectedPet!.id); + } else { + _selectedPet = _pets.first; + } + } + } else { + _selectedPet = null; + } + } catch (e) { + debugPrint('Error fetching pets: $e'); + _pets = []; + } + } + + // Refresh data (e.g. after add/edit) + Future refreshPets() async { + await _fetchPets(); + notifyListeners(); + } + + // Select a pet + void selectPet(Pet pet) { + _selectedPet = pet; + notifyListeners(); + } + + // Clear state (e.g. on logout) + void clearState() { + _userId = null; + _pets = []; + _selectedPet = null; + notifyListeners(); + } +} diff --git a/app/lib/screens/daily_care_screen.dart b/app/lib/screens/daily_care_screen.dart index f2f70d8..00c7c9a 100644 --- a/app/lib/screens/daily_care_screen.dart +++ b/app/lib/screens/daily_care_screen.dart @@ -3,9 +3,14 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:intl/intl.dart'; -import '../models/pet_model.dart'; -import '../services/firestore_service.dart'; +import 'package:provider/provider.dart'; +import '../models/schedule_model.dart'; +import '../services/api_service.dart'; import '../theme/app_colors.dart'; +import '../widgets/common/pet_selection_header.dart'; +import 'schedule_form_screen.dart'; +import '../providers/pet_provider.dart'; +import 'pet_form_screen.dart'; class DailyCareScreen extends StatefulWidget { const DailyCareScreen({super.key}); @@ -15,11 +20,10 @@ class DailyCareScreen extends StatefulWidget { } class _DailyCareScreenState extends State { - final FirestoreService _firestoreService = FirestoreService(); DateTime _focusedDay = DateTime.now(); DateTime _selectedDay = DateTime.now(); - String? _userId; - Pet? _selectedPet; + bool _isStampMode = false; + final DraggableScrollableController _sheetController = DraggableScrollableController(); @@ -29,77 +33,161 @@ class _DailyCareScreenState extends State { super.dispose(); } + // 날짜 정규화 (시간 제거) DateTime _normalizeDate(DateTime date) { return DateTime(date.year, date.month, date.day); } - late final Map> _events; - @override void initState() { super.initState(); - _userId = _firestoreService.getCurrentUserId(); - - final today = _normalizeDate(DateTime.now()); - _events = { - today: [ - 'flower', - 'flower', - 'flower', - 'flower', - 'incomplete', - 'flower', - 'important', - 'general', - 'general', - ], // 오늘: 9개 (3x3 테스트) - today.subtract(const Duration(days: 1)): [ - 'flower', - 'flower', - 'flower', - ], // 어제: 완료3 - today.subtract(const Duration(days: 2)): ['general'], // 2일전: 일반1 - today.subtract(const Duration(days: 3)): [ - 'incomplete', - 'flower', - ], // 3일전: 실패1, 완료1 - today.subtract(const Duration(days: 5)): [ - 'flower', - 'flower', - 'flower', - ], // 5일전: 완료3 - today.subtract(const Duration(days: 7)): [ - 'incomplete', - 'incomplete', - ], // 7일전: 실패2 - today.subtract(const Duration(days: 10)): [ - 'important', - 'general', - ], // 10일전: 중요1, 일반1 - }; + WidgetsBinding.instance.addPostFrameCallback((_) { + final provider = context.read(); + if (provider.userId == null) { + provider.loadUserAndPets(); + } + }); } - List _getEventsForDay(DateTime day) { - return _events[_normalizeDate(day)] ?? []; + List _getSchedulesForDay( + DateTime day, + Map> scheduleMap, + ) { + return scheduleMap[_normalizeDate(day)] ?? []; + } + + // Data Variables + List _schedules = []; + bool _isLoadingSchedules = false; + String? _lastPetId; + DateTime? _lastFocusedMonth; + final ApiService _apiService = ApiService(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _fetchSchedulesIfNeeded(); + } + + Future _fetchSchedulesIfNeeded() async { + final petProvider = context.read(); + final selectedPet = petProvider.selectedPet; + + if (selectedPet == null) { + setState(() { + _schedules = []; + _lastPetId = null; + }); + return; + } + + bool shouldUpdate = false; + // Check if Pet Changed + if (_lastPetId != selectedPet.id) { + shouldUpdate = true; + } + // Check if Month Changed (only year/month matters) + if (_lastFocusedMonth == null || + _lastFocusedMonth!.year != _focusedDay.year || + _lastFocusedMonth!.month != _focusedDay.month) { + shouldUpdate = true; + } + + if (shouldUpdate) { + _lastPetId = selectedPet.id; + _lastFocusedMonth = _focusedDay; + await _fetchSchedules(selectedPet.id, _focusedDay); + } + } + + Future _fetchSchedules(String petId, DateTime date) async { + setState(() { + _isLoadingSchedules = true; + }); + + try { + final data = await _apiService.getSchedules( + petId: petId, + year: date.year, + month: date.month, + ); + + final List fetchedSchedules = data + .map((item) => Schedule.fromMap(item)) + .toList(); + + if (mounted) { + setState(() { + _schedules = fetchedSchedules; + _isLoadingSchedules = false; + }); + } + } catch (e) { + debugPrint('Error fetching schedules: $e'); + if (mounted) { + setState(() { + _isLoadingSchedules = false; + // Optional: Show error + }); + } + } + } + + void _onMonthChanged(DateTime focusedDay) { + setState(() { + _focusedDay = focusedDay; + }); + // Trigger fetch manually since state updated + final petProvider = context.read(); + if (petProvider.selectedPet != null) { + _fetchSchedules(petProvider.selectedPet!.id, focusedDay); + _lastFocusedMonth = focusedDay; // Update cache key + } } @override Widget build(BuildContext context) { + final petProvider = context.watch(); + final selectedPet = petProvider.selectedPet; + return Scaffold( backgroundColor: Colors.white, body: SafeArea( child: Stack( children: [ Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildHeader(), - SizedBox(height: 30.h), // Equal spacing 1 - _buildCustomCalendarHeader(), // Custom Header - SizedBox(height: 30.h), // Equal spacing 2 + _buildHeader(petProvider), + SizedBox(height: 10.h), + _buildCustomCalendarHeader(), + SizedBox(height: 30.h), Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 20.w), - child: _buildCalendar(), + child: selectedPet == null + ? const Center(child: Text("반려동물을 선택해주세요")) + : Builder( + builder: (context) { + if (_isLoadingSchedules && _schedules.isEmpty) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // 데이터 처리: List -> Map> + Map> scheduleMap = {}; + for (var schedule in _schedules) { + final date = _normalizeDate(schedule.date); + if (scheduleMap[date] == null) { + scheduleMap[date] = []; + } + scheduleMap[date]!.add(schedule); + } + + return _buildCalendar(scheduleMap); + }, + ), ), ), ], @@ -111,76 +199,63 @@ class _DailyCareScreenState extends State { floatingActionButton: FloatingActionButton( heroTag: 'daily_care_fab', onPressed: () { - // 일정 추가 로직 (추후 구현) - ScaffoldMessenger.of( + if (petProvider.selectedPet == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('반려동물을 먼저 선택해주세요.'))); + return; + } + + Navigator.push( context, - ).showSnackBar(const SnackBar(content: Text('일정 추가 기능은 준비 중입니다.'))); + MaterialPageRoute( + builder: (context) => + ScheduleFormScreen(selectedDate: _selectedDay), + ), + ); }, backgroundColor: AppColors.highlight, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), + borderRadius: BorderRadius.circular(12.r), + ), + child: SvgPicture.asset( + 'assets/icons/add_icon.svg', + width: 24.w, + height: 24.w, ), - child: const Icon(Icons.add, color: Colors.white), ), ); } - Widget _buildHeader() { + Widget _buildHeader(PetProvider petProvider) { return Padding( - padding: EdgeInsets.only(left: 20.w, right: 20.w, top: 10.h, bottom: 0), - child: StreamBuilder>( - stream: _userId != null - ? _firestoreService.getPets(_userId!) - : const Stream.empty(), - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data!.isEmpty) { + padding: EdgeInsets.only(left: 10.w, right: 20.w, top: 6.h, bottom: 0), + child: Builder( + builder: (context) { + final pets = petProvider.pets; + final selectedPet = petProvider.selectedPet; + final isLoading = petProvider.isLoading; + + if (isLoading) { return const SizedBox.shrink(); } - final pets = snapshot.data!; - if (_selectedPet == null || - !pets.any((p) => p.id == _selectedPet!.id)) { - _selectedPet = pets.first; - } - - return Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - InkWell( - onTap: () {}, - child: Row( - children: [ - CircleAvatar( - radius: 20.r, - backgroundColor: Colors.grey[200], - backgroundImage: _selectedPet!.profileImageUrl != null - ? NetworkImage(_selectedPet!.profileImageUrl!) - : null, - child: _selectedPet!.profileImageUrl == null - ? SvgPicture.asset( - 'assets/icons/profile_icon.svg', - width: 20.w, - colorFilter: ColorFilter.mode( - Colors.grey[400]!, - BlendMode.srcIn, - ), - ) - : null, - ), - SizedBox(width: 8.w), - Text( - _selectedPet!.name, - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 18.sp, - fontWeight: FontWeight.bold, - ), - ), - Icon(Icons.keyboard_arrow_down, size: 24.w), - ], - ), - ), - ], + return PetSelectionHeader( + pets: pets, + selectedPet: selectedPet, + onPetSelected: (value) { + context.read().selectPet(value); + }, + onAddPetPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const PetFormScreen()), + ).then((value) { + if (value == true) { + context.read().refreshPets(); + } + }); + }, ); }, ), @@ -189,9 +264,7 @@ class _DailyCareScreenState extends State { Widget _buildCustomCalendarHeader() { return Padding( - padding: EdgeInsets.symmetric( - horizontal: 20.w, - ), // Removed vertical padding + padding: EdgeInsets.symmetric(horizontal: 20.w), child: Stack( alignment: Alignment.center, children: [ @@ -200,16 +273,13 @@ class _DailyCareScreenState extends State { children: [ InkWell( onTap: () { - setState(() { - _focusedDay = DateTime( - _focusedDay.year, - _focusedDay.month - 1, - ); - }); + _onMonthChanged( + DateTime(_focusedDay.year, _focusedDay.month - 1), + ); }, child: SvgPicture.asset( 'assets/icons/left_icon.svg', - width: 12.w, // Changed to 12px + width: 12.w, height: 12.w, ), ), @@ -225,16 +295,13 @@ class _DailyCareScreenState extends State { SizedBox(width: 30.w), InkWell( onTap: () { - setState(() { - _focusedDay = DateTime( - _focusedDay.year, - _focusedDay.month + 1, - ); - }); + _onMonthChanged( + DateTime(_focusedDay.year, _focusedDay.month + 1), + ); }, child: SvgPicture.asset( 'assets/icons/right_icon.svg', - width: 12.w, // Changed to 12px + width: 12.w, height: 12.w, ), ), @@ -242,14 +309,23 @@ class _DailyCareScreenState extends State { ), Positioned( right: 0, - child: Container( - width: 40.w, - height: 40.w, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(12.r), + child: InkWell( + onTap: () { + setState(() { + _isStampMode = !_isStampMode; + }); + }, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: EdgeInsets.all(4.w), + child: SvgPicture.asset( + _isStampMode + ? 'assets/icons/dashboard_icon.svg' + : 'assets/icons/stamp_icon.svg', + width: 24.w, + height: 24.w, + ), ), - child: const Icon(Icons.download, color: Colors.white, size: 20), ), ), ], @@ -257,26 +333,43 @@ class _DailyCareScreenState extends State { ); } - Widget _buildCalendar() { - return TableCalendar( + Widget _buildCalendar(Map> scheduleMap) { + return TableCalendar( shouldFillViewport: false, locale: 'ko_KR', - rowHeight: 85 - .h, // Increased to fit 3 rows of icons (approx 40px + 32px top offset) + rowHeight: 85.h, daysOfWeekHeight: 30.h, firstDay: DateTime.utc(2020, 1, 1), lastDay: DateTime.utc(2030, 12, 31), focusedDay: _focusedDay, selectedDayPredicate: (day) => isSameDay(_selectedDay, day), - headerVisible: false, // Hide default header + headerVisible: false, onDaySelected: (selectedDay, focusedDay) { - setState(() { - _selectedDay = selectedDay; - _focusedDay = focusedDay; - }); + // Update selection + if (!isSameDay(_selectedDay, selectedDay)) { + setState(() { + _selectedDay = selectedDay; + }); + } + + // Update focused month if changed (triggers stream update) + if (focusedDay.month != _focusedDay.month || + focusedDay.year != _focusedDay.year) { + _onMonthChanged(focusedDay); + } else { + // Just update focused day references without stream update + if (!isSameDay(_focusedDay, focusedDay)) { + setState(() { + _focusedDay = focusedDay; + }); + } + } }, onPageChanged: (focusedDay) { - _focusedDay = focusedDay; + if (focusedDay.month != _focusedDay.month || + focusedDay.year != _focusedDay.year) { + _onMonthChanged(focusedDay); + } }, calendarStyle: CalendarStyle( defaultTextStyle: TextStyle( @@ -306,24 +399,40 @@ class _DailyCareScreenState extends State { selectedTextStyle: const TextStyle( color: Colors.black, fontWeight: FontWeight.bold, - decoration: TextDecoration.underline, ), - outsideDaysVisible: true, // Show outside days + outsideDaysVisible: true, ), - eventLoader: _getEventsForDay, // 이벤트 로더 + eventLoader: (day) => _getSchedulesForDay(day, scheduleMap), calendarBuilders: CalendarBuilders( - // 마커 커스텀 빌더 markerBuilder: (context, day, events) { if (events.isEmpty) return null; - // 외부 날짜인지 확인 (투명도 적용을 위해) - // 주의: _focusedDay는 페이지가 바뀔 때만 업데이트되므로, - // 달력의 현재 페이지 월과 day의 월을 비교해야 함. - // 하지만 markerBuilder는 페이징 중에 다시 빌드될 수 있음. bool isOutside = day.month != _focusedDay.month; - double opacity = isOutside ? 0.5 : 1.0; + double opacity = isOutside ? 0.3 : 1.0; + + if (_isStampMode) { + int total = events.length; + int successCount = events.where((s) => s.isCompleted).length; + bool isGood = (successCount / total) >= 0.5; + + return Positioned( + top: 40.h, + left: 0, + right: 0, + child: Center( + child: Opacity( + opacity: opacity, + child: Image.asset( + isGood ? 'assets/img/good.png' : 'assets/img/bad.png', + width: 24.w, + height: 24.w, + fit: BoxFit.contain, + ), + ), + ), + ); + } - // 이벤트가 있으면 아이콘 표시 (최대 9개 등 제한 가능) return Positioned( top: 32.h, left: 0, @@ -332,28 +441,25 @@ class _DailyCareScreenState extends State { opacity: opacity, child: Center( child: Container( - width: - 40.w, // Exact width: 12*3 + 2*2 = 40. Centers perfectly. + width: 40.w, child: Wrap( - alignment: WrapAlignment.start, // Left aligned - spacing: 2.w, // Horizontal spacing - runSpacing: 2.h, // Vertical spacing - children: events.take(9).map((event) { + alignment: WrapAlignment.start, + spacing: 2.w, + runSpacing: 2.h, + children: events.take(9).map((schedule) { String iconPath = 'assets/icons/general_schedule_icon.svg'; - switch (event) { - case 'flower': - iconPath = 'assets/icons/flower_icon.svg'; - break; - case 'incomplete': - iconPath = 'assets/icons/incomplete_icon.svg'; - break; - case 'important': + + // 상태/타입에 따른 아이콘 결정 + if (schedule.isCompleted) { + iconPath = 'assets/icons/flower_icon.svg'; + } else { + if (schedule.type == 'important') { iconPath = 'assets/icons/important_schedule_icon.svg'; - break; - case 'general': - iconPath = 'assets/icons/general_schedule_icon.svg'; - break; + } else { + // 일반 일정 미완료 + iconPath = 'assets/icons/incomplete_icon.svg'; + } } return SvgPicture.asset( @@ -368,16 +474,12 @@ class _DailyCareScreenState extends State { ), ); }, - // 요일 헤더 커스텀 dowBuilder: (context, day) { final text = DateFormat.E('ko_KR').format(day); - // 요일 헤더는 '현재 달' 개념이 모호하므로(항상 보임) 기본 색상 사용 Color color = const Color(0xFF939393); - if (day.weekday == DateTime.sunday) - color = const Color(0xFFFF3F3F); // 일요일 #FF3F3F로 복귀 + if (day.weekday == DateTime.sunday) color = const Color(0xFFFF3F3F); return Container( - // Removed bottom border to avoid double border with first row's top border padding: EdgeInsets.only(bottom: 10.h), alignment: Alignment.bottomCenter, child: Text( @@ -391,113 +493,59 @@ class _DailyCareScreenState extends State { ), ); }, - // 외부 날짜 (지난달/다음달) 커스텀 outsideBuilder: (context, day, focusedDay) { - // 기본 색상에서 투명도 50% 적용 - Color color = const Color(0xFF1F1F1F).withOpacity(0.5); // 평일 50% + Color color = const Color(0xFF1F1F1F).withOpacity(0.5); if (day.weekday == DateTime.sunday) { - color = const Color(0xFFFF3F3F).withOpacity(0.5); // 일요일 50% + color = const Color(0xFFFF3F3F).withOpacity(0.5); } - - return Container( - decoration: const BoxDecoration( - border: Border( - top: BorderSide( - color: Color(0xFFE0E0E0), - width: 1, - ), // Changed to top - ), - ), - padding: EdgeInsets.only(top: 8.h), - alignment: Alignment.topCenter, - child: Text( - day.day.toString().padLeft(2, '0'), - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 15.sp, - fontWeight: FontWeight.w500, - color: color, - ), - ), - ); + return _buildDayCell(day, color); }, - // 날짜 셀 커스텀 defaultBuilder: (context, day, focusedDay) { - // 현재 달 날짜 색상 Color color = const Color(0xFF1F1F1F); if (day.weekday == DateTime.sunday) { color = const Color(0xFFFF3F3F); } - - return Container( - decoration: const BoxDecoration( - border: Border( - top: BorderSide( - color: Color(0xFFE0E0E0), - width: 1, - ), // Changed to top - ), - ), - padding: EdgeInsets.only(top: 8.h), // Move closer to top line - alignment: Alignment.topCenter, // Align to top - child: Text( - day.day.toString().padLeft(2, '0'), - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 15.sp, - fontWeight: FontWeight.w500, - color: color, - ), - ), - ); + return _buildDayCell(day, color); }, - // 오늘/선택 날짜 커스텀 prioritizedBuilder: (context, day, focusedDay) { final isToday = isSameDay(day, DateTime.now()); final isSelected = isSameDay(day, _selectedDay); - // 우선순위가 없는 날짜는 null을 반환하여 default/outsideBuilder가 실행되도록 함 if (!isToday && !isSelected) return null; final isSunday = day.weekday == DateTime.sunday; - - // 텍스트 색상 결정 - Color textColor = const Color(0xFF1F1F1F); // 기본 검정 + Color textColor = const Color(0xFF1F1F1F); if (isToday) { - textColor = const Color(0xFFFF9500); // 오늘: #FF9500 (유지) + textColor = const Color(0xFFFF9500); } else if (isSunday) { - textColor = const Color(0xFFFF3F3F); // 일요일: #FF3F3F + textColor = const Color(0xFFFF3F3F); } - // 배경 색상/데코레이션 결정 (선택된 경우) BoxDecoration? innerDecoration; if (isSelected) { innerDecoration = BoxDecoration( - color: const Color(0xFFFFEDBC), // 선택 배경: #FFEDBC - borderRadius: BorderRadius.circular(6.r), // 모서리 덜 둥글게 (6) + color: const Color(0xFFFFEDBC), + borderRadius: BorderRadius.circular(6.r), ); } return Container( - // 바깥 컨테이너: 행 구분선 담당 decoration: const BoxDecoration( border: Border( top: BorderSide(color: Color(0xFFE0E0E0), width: 1), ), ), child: Container( - // 내부 컨테이너: 선택 배경 담당 (가로 여백 제거로 넓게) margin: EdgeInsets.symmetric(vertical: 2.h), decoration: innerDecoration, - alignment: Alignment.topCenter, // 상단 정렬 - padding: EdgeInsets.only(top: 6.h), // 텍스트 위 여백 + alignment: Alignment.topCenter, + padding: EdgeInsets.only(top: 6.h), child: Text( day.day.toString().padLeft(2, '0'), style: TextStyle( fontFamily: 'SCDream', fontSize: 15.sp, - fontWeight: FontWeight.bold, // 오늘/선택은 강조 - decoration: isSelected ? null : null, + fontWeight: FontWeight.bold, color: textColor, ), ), @@ -508,9 +556,30 @@ class _DailyCareScreenState extends State { ); } + Widget _buildDayCell(DateTime day, Color textColor) { + return Container( + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: Color(0xFFE0E0E0), width: 1)), + ), + padding: EdgeInsets.only(top: 8.h), + alignment: Alignment.topCenter, + child: Text( + day.day.toString().padLeft(2, '0'), + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 15.sp, + fontWeight: FontWeight.w500, + color: textColor, + ), + ), + ); + } + Widget _buildBottomSheet() { + // 임시로 비워둠 (실제 리스트 구현 시 채울 예정) + // 현재는 일정 추가만 구현 return DraggableScrollableSheet( - controller: _sheetController, // 컨트롤러 연결 + controller: _sheetController, initialChildSize: 0.08, minChildSize: 0.08, maxChildSize: 0.8, @@ -522,9 +591,9 @@ class _DailyCareScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(24.r)), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), // Lighter shadow - blurRadius: 5, // Tighter blur - offset: const Offset(0, -3), // Closer offset + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, -3), ), ], ), @@ -534,10 +603,8 @@ class _DailyCareScreenState extends State { padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), child: Column( children: [ - // 헤더 (터치 영역) GestureDetector( onTap: () { - // 현재 높이에 따라 토글 if (_sheetController.size > 0.4) { _sheetController.animateTo( 0.08, @@ -553,31 +620,14 @@ class _DailyCareScreenState extends State { } }, child: Container( - color: Colors.transparent, // 터치 영역 확장 + color: Colors.transparent, width: double.infinity, child: Column( children: [ - AnimatedBuilder( - animation: _sheetController, - builder: (context, child) { - // 0.08 ~ 0.8 사이 값에서 회전 각도 계산 - // 0.4 이상이면 완전히 회전하도록 설정하거나, 비례해서 회전 - // 간단하게 0.4 기준으로 상태 판단 - final double angle = - _sheetController.isAttached && - _sheetController.size > 0.4 - ? 3.14159 - : 0.0; - - return Transform.rotate( - angle: angle, - child: Icon( - Icons.keyboard_arrow_up_rounded, - color: Colors.grey[400], - size: 24.w, - ), - ); - }, + Icon( + Icons.keyboard_arrow_up_rounded, + color: Colors.grey[400], + size: 24.w, ), SizedBox(height: 20.h), ], @@ -587,7 +637,7 @@ class _DailyCareScreenState extends State { Align( alignment: Alignment.centerLeft, child: Text( - '중요 일정 1', + '일정 리스트 준비 중...', // TODO: Implement list style: TextStyle( fontFamily: 'SCDream', fontSize: 14.sp, @@ -595,7 +645,7 @@ class _DailyCareScreenState extends State { ), ), ), - SizedBox(height: 50.h), // 스크롤 테스트용 여백 + SizedBox(height: 50.h), ], ), ), diff --git a/app/lib/screens/home_screen.dart b/app/lib/screens/home_screen.dart index 0ced3fc..19099de 100644 --- a/app/lib/screens/home_screen.dart +++ b/app/lib/screens/home_screen.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:provider/provider.dart'; import 'pet_form_screen.dart'; -import '../services/api_service.dart'; -import '../services/auth_service.dart'; import '../models/pet_model.dart'; import '../theme/app_colors.dart'; import '../widgets/home/pet_profile_card.dart'; +import '../widgets/common/pet_selection_header.dart'; +import '../providers/pet_provider.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -16,124 +17,68 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - final ApiService _apiService = ApiService(); - final AuthService _authService = AuthService(); - int? _userId; - Pet? _selectedPet; - Future>? _petsFuture; - @override void initState() { super.initState(); - _loadUserAndPets(); - } - - Future _loadUserAndPets() async { - final userInfo = await _authService.getUserInfo(); - if (userInfo != null) { - setState(() { - _userId = userInfo['id'] is int - ? userInfo['id'] - : int.tryParse(userInfo['id'].toString()); - _petsFuture = _fetchPets(); - }); - } - } - - Future> _fetchPets() async { - if (_userId == null) return []; - try { - final petsData = await _apiService.getPets(_userId!); - return petsData.map((e) => Pet.fromMap(e)).toList(); - } catch (e) { - debugPrint('Error loading pets: $e'); - return []; - } - } - - void _refreshPets() { - if (_userId != null) { - setState(() { - _petsFuture = _fetchPets(); - }); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + final provider = context.read(); + if (provider.userId == null) { + provider.loadUserAndPets(); + } + }); } @override Widget build(BuildContext context) { - if (_userId == null) { + final petProvider = context.watch(); + final pets = petProvider.pets; + final displayPet = petProvider.selectedPet; + final isLoading = petProvider.isLoading; + + if (isLoading) { + return const Scaffold( + backgroundColor: Colors.white, + body: Center(child: CircularProgressIndicator()), + ); + } + + // Check login + if (petProvider.userId == null && !isLoading) { return const Scaffold(body: Center(child: Text('로그인이 필요합니다.'))); } return Scaffold( backgroundColor: Colors.white, body: SafeArea( - child: FutureBuilder>( - future: _petsFuture, - 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 유지 (등록 버튼 강조) + child: Builder( + builder: (context) { if (pets.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: EdgeInsets.symmetric( - horizontal: 20.w, - vertical: 20.h, + padding: EdgeInsets.only( + left: 20.w, + right: 20.w, + top: 6.h, + bottom: 20.h, ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(8.r), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const PetFormScreen(), - ), - ).then((value) { - if (value == true) _refreshPets(); - }); - }, - child: Padding( - padding: EdgeInsets.only( - top: 4.h, - bottom: 4.h, - right: 12.w, - ), // Added right padding - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset( - 'assets/img/profile.png', - width: 40.w, - height: 40.h, - ), - SizedBox(width: 6.w), // Reduced spacing - Text( - '반려동물 등록 +', - style: TextStyle( - fontFamily: 'SCDream', - fontWeight: FontWeight.w500, - fontSize: 15.sp, - letterSpacing: 0.45.sp, - color: const Color(0xFF1f1f1f), - ), - ), - ], + child: PetSelectionHeader( + pets: [], + selectedPet: null, + onPetSelected: (_) {}, + onAddPetPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PetFormScreen(), ), - ), - ), + ).then((value) { + if (value == true) { + context.read().refreshPets(); + } + }); + }, ), ), Expanded( @@ -154,172 +99,37 @@ class _HomeScreenState extends State { } // 등록된 반려동물이 있을 때 - // 선택된 펫이 없거나 리스트에 없으면 첫 번째 펫 선택 - // 등록된 반려동물이 있을 때 - Pet displayPet; - - // 선택된 펫이 없거나 리스트에 없으면 첫 번째 펫 선택 (State 변경 없이 화면 표시만 처리) - if (_selectedPet != null && - pets.any((p) => p.id == _selectedPet!.id)) { - displayPet = pets.firstWhere((p) => p.id == _selectedPet!.id); - } else { - displayPet = pets.first; - } + // displayPet derived from provider (already defined above) + final currentPet = displayPet ?? pets.first; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: EdgeInsets.symmetric( - horizontal: 20.w, - vertical: 20.h, + padding: EdgeInsets.only( + left: 10.w, + right: 20.w, + top: 6.h, + bottom: 0, ), - child: PopupMenuButton( - offset: Offset(0, 50.h), // 헤더 바로 아래에 위치하도록 조정 - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.r), - ), - color: Colors.white, - surfaceTintColor: Colors.white, - onSelected: (value) { - if (value is Pet) { - setState(() { - _selectedPet = value; - }); - } else if (value == 'add_pet') { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const PetFormScreen(), - ), - ).then((value) { - if (value == true) _refreshPets(); - }); - } + child: PetSelectionHeader( + pets: pets, + selectedPet: displayPet, // displayPet comes from provider + onPetSelected: (value) { + context.read().selectPet(value); }, - itemBuilder: (context) { - return [ - ...pets.map( - (pet) => PopupMenuItem( - value: pet, - child: Row( - children: [ - CircleAvatar( - radius: 16.r, - backgroundColor: Colors.grey[200], - backgroundImage: pet.profileImageUrl != null - ? NetworkImage(pet.profileImageUrl!) - : null, - child: pet.profileImageUrl == null - ? SvgPicture.asset( - 'assets/icons/profile_icon.svg', - width: 16.w, - colorFilter: ColorFilter.mode( - Colors.grey[400]!, - BlendMode.srcIn, - ), - ) - : null, - ), - SizedBox(width: 10.w), - Text( - pet.name, - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 14.sp, - fontWeight: pet.id == displayPet.id - ? FontWeight.bold - : FontWeight.normal, - color: pet.id == displayPet.id - ? AppColors.highlight - : Colors.black, - ), - ), - if (pet.id == displayPet.id) ...[ - const Spacer(), - const Icon( - Icons.check, - color: AppColors.highlight, - size: 16, - ), - ], - ], - ), - ), + onAddPetPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PetFormScreen(), ), - const PopupMenuDivider(), - PopupMenuItem( - value: 'add_pet', - child: Row( - children: [ - Container( - padding: EdgeInsets.all(4.w), - decoration: BoxDecoration( - color: Colors.grey[100], - shape: BoxShape.circle, - ), - child: Icon( - Icons.add, - size: 16.w, - color: Colors.black54, - ), - ), - SizedBox(width: 10.w), - Text( - '반려동물 추가하기', - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 14.sp, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ]; + ).then((value) { + if (value == true) { + context.read().refreshPets(); + } + }); }, - 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( @@ -327,8 +137,10 @@ class _HomeScreenState extends State { child: Column( children: [ PetProfileCard( - pet: displayPet, - onPetUpdated: _refreshPets, + pet: currentPet, // Use safe currentPet + onPetUpdated: () { + context.read().refreshPets(); + }, ), ], ), diff --git a/app/lib/screens/identity_verification_screen.dart b/app/lib/screens/identity_verification_screen.dart index d6a8d55..bc6e37a 100644 --- a/app/lib/screens/identity_verification_screen.dart +++ b/app/lib/screens/identity_verification_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import screenutil import 'main_screen.dart'; +import '../widgets/common/custom_app_bar.dart'; class IdentityVerificationScreen extends StatelessWidget { const IdentityVerificationScreen({super.key}); @@ -9,24 +10,7 @@ class IdentityVerificationScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - icon: Icon(Icons.arrow_back_ios, color: Colors.black, size: 20.w), - onPressed: () => Navigator.pop(context), - ), - title: Text( - '본인 인증', - style: TextStyle( - fontSize: 15.sp, - fontFamily: 'SCDream', - fontWeight: FontWeight.w500, - color: Colors.black, - ), - ), - centerTitle: true, - ), + appBar: const CustomAppBar(title: '본인 인증'), body: Padding( padding: EdgeInsets.all(20.0.w), child: Column( diff --git a/app/lib/screens/login_screen.dart b/app/lib/screens/login_screen.dart index 65f7b1a..c3d739e 100644 --- a/app/lib/screens/login_screen.dart +++ b/app/lib/screens/login_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import screenuti import '../services/auth_service.dart'; import 'main_screen.dart'; import 'terms_agreement_screen.dart'; // Import TermsAgreementScreen +import '../widgets/common/custom_app_bar.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -68,28 +69,7 @@ class _LoginScreenState extends State { children: [ Scaffold( backgroundColor: Colors.white, - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - icon: Icon( - Icons.arrow_back_ios, - color: Colors.black, - size: 16.w, // Responsive size - ), - onPressed: () => Navigator.pop(context), - ), - title: Text( - '간편 로그인', - style: TextStyle( - fontSize: 15.sp, // Responsive font size - fontFamily: 'SCDream', - fontWeight: FontWeight.w500, - color: Colors.black, - ), - ), - centerTitle: true, - ), + appBar: const CustomAppBar(title: '간편 로그인'), body: Padding( padding: EdgeInsets.fromLTRB( 20.w, diff --git a/app/lib/screens/main_screen.dart b/app/lib/screens/main_screen.dart index a862b64..4a7c62b 100644 --- a/app/lib/screens/main_screen.dart +++ b/app/lib/screens/main_screen.dart @@ -6,8 +6,6 @@ import 'daily_care_screen.dart'; import 'mungnyangz_screen.dart'; import 'my_info_screen.dart'; -import '../theme/app_colors.dart'; - class MainScreen extends StatefulWidget { const MainScreen({super.key}); @@ -22,7 +20,7 @@ class _MainScreenState extends State { final List _screens = [ const HomeScreen(), const DailyCareScreen(), - const MungNyangzScreen(), // 멍냥즈 (샵 대신) + const MungNyangzScreen(), const MyInfoScreen(), ]; @@ -39,9 +37,7 @@ class _MainScreenState extends State { width: 24.w, height: 24.h, colorFilter: ColorFilter.mode( - _selectedIndex == index - ? AppColors.highlight - : AppColors.inactive, // 선택됨: 강조색, 안됨: 비활성화색 + _selectedIndex == index ? Colors.black : Colors.grey, BlendMode.srcIn, ), ); @@ -64,59 +60,51 @@ class _MainScreenState extends State { ), child: BottomNavigationBar( backgroundColor: Colors.white, - elevation: 0, // Remove default shadow since we have a border + elevation: 0, currentIndex: _selectedIndex, onTap: _onItemTapped, type: BottomNavigationBarType.fixed, - selectedItemColor: AppColors.highlight, - unselectedItemColor: AppColors.inactive, + selectedItemColor: Colors.black, + unselectedItemColor: Colors.grey, selectedLabelStyle: TextStyle( fontFamily: 'SCDream', - fontSize: 12.sp, - fontWeight: FontWeight.w500, // Medium + fontSize: 14.sp, + fontWeight: FontWeight.bold, + color: Colors.black, ), unselectedLabelStyle: TextStyle( fontFamily: 'SCDream', - fontSize: 12.sp, - fontWeight: FontWeight.w400, // Regular + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: Colors.grey, ), showUnselectedLabels: true, items: [ BottomNavigationBarItem( icon: Padding( - padding: EdgeInsets.only(bottom: 10.h), + padding: EdgeInsets.only(bottom: 8.h), child: _buildSvgIcon('assets/icons/home_icon.svg', 0), ), label: '홈', ), BottomNavigationBarItem( icon: Padding( - padding: EdgeInsets.only(bottom: 10.h), + padding: EdgeInsets.only(bottom: 8.h), child: _buildSvgIcon('assets/icons/calendar_icon.svg', 1), ), label: '데일리케어', ), BottomNavigationBarItem( icon: Padding( - padding: EdgeInsets.only(bottom: 10.h), - child: Image.asset( - _selectedIndex == 2 - ? 'assets/img/catdog_on.png' - : 'assets/img/catdog_off.png', - width: 29.w, - height: 26.h, - fit: BoxFit.cover, - ), + padding: EdgeInsets.only(bottom: 8.h), + child: _buildSvgIcon('assets/icons/card_icon.svg', 2), ), - label: '멍냥즈', + label: '펫 명함함', ), BottomNavigationBarItem( icon: Padding( - padding: EdgeInsets.only(bottom: 10.h), - child: _buildSvgIcon( - 'assets/icons/my_icon.svg', - 3, - ), // Index adjusted + padding: EdgeInsets.only(bottom: 8.h), + child: _buildSvgIcon('assets/icons/my_icon.svg', 3), ), label: '내 정보', ), diff --git a/app/lib/screens/my_info_screen.dart b/app/lib/screens/my_info_screen.dart index dd53ce9..085adff 100644 --- a/app/lib/screens/my_info_screen.dart +++ b/app/lib/screens/my_info_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../services/auth_service.dart'; +import '../providers/pet_provider.dart'; import 'welcome_screen.dart'; import 'notice_screen.dart'; import '../data/terms_data.dart'; @@ -46,6 +48,10 @@ class _MyInfoScreenState extends State { } Future _handleLogout() async { + // Clear Provider State before logging out (to prevent data leak) + if (mounted) { + context.read().clearState(); + } await _authService.signOut(); if (mounted) { Navigator.of(context).pushAndRemoveUntil( @@ -76,6 +82,9 @@ class _MyInfoScreenState extends State { if (confirm == true) { if (!mounted) return; + // Clear Provider State before withdrawal + context.read().clearState(); + final success = await _authService.withdrawAccount(); if (success && mounted) { Navigator.of(context).pushAndRemoveUntil( diff --git a/app/lib/screens/pet_form_screen.dart b/app/lib/screens/pet_form_screen.dart index 0ed1604..6a4a743 100644 --- a/app/lib/screens/pet_form_screen.dart +++ b/app/lib/screens/pet_form_screen.dart @@ -11,6 +11,7 @@ import '../models/pet_model.dart'; import '../services/api_service.dart'; import '../services/auth_service.dart'; import '../widgets/pet_registration/input_formatters.dart'; +import '../widgets/common/custom_app_bar.dart'; class PetFormScreen extends StatefulWidget { final Pet? petToEdit; @@ -387,25 +388,20 @@ class _PetFormScreenState extends State { ); } - List finalDiseases = List.from(_selectedDiseases); - if (finalDiseases.contains('기타') && _otherDiseaseText.isNotEmpty) { - finalDiseases.remove('기타'); - finalDiseases.add('기타($_otherDiseaseText)'); - } + List finalDiseases = _processSelectionForSubmit( + _selectedDiseases, + _otherDiseaseText, + ); - List finalPastDiseases = List.from(_selectedPastDiseases); - if (finalPastDiseases.contains('기타') && - _otherPastDiseaseText.isNotEmpty) { - finalPastDiseases.remove('기타'); - finalPastDiseases.add('기타($_otherPastDiseaseText)'); - } + List finalPastDiseases = _processSelectionForSubmit( + _selectedPastDiseases, + _otherPastDiseaseText, + ); - List finalHealthConcerns = List.from(_selectedHealthConcerns); - if (finalHealthConcerns.contains('기타') && - _otherHealthConcernText.isNotEmpty) { - finalHealthConcerns.remove('기타'); - finalHealthConcerns.add('기타($_otherHealthConcernText)'); - } + List finalHealthConcerns = _processSelectionForSubmit( + _selectedHealthConcerns, + _otherHealthConcernText, + ); // 수정 모드 if (widget.petToEdit != null) { @@ -823,72 +819,24 @@ class _PetFormScreenState extends State { child: showInput ? Padding( padding: EdgeInsets.all(20.w), - child: Column( - children: [ - Text( - '반려동물의 종을 직접 입력해주세요.', - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 16.sp, - color: Colors.black87, - ), - ), - SizedBox(height: 20.h), - TextField( - controller: speciesInputController, - autofocus: true, - decoration: const InputDecoration( - hintText: '예: 미어캣, 라쿤 등', - border: OutlineInputBorder(), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: AppColors.highlight, - ), - ), - ), - ), - const Spacer(), - SizedBox( - width: double.infinity, - height: 52.h, - child: ElevatedButton( - onPressed: () { - if (speciesInputController - .text - .isNotEmpty) { - setState(() { - _speciesController.text = - speciesInputController.text; - // 직접 입력 시 카테고리 정보 초기화 (품종 선택 불가 또는 직접 입력) - _currentMajorCategory = null; - _currentMinorCategory = null; - _breedController.clear(); - }); - Navigator.pop(context); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.highlight, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 12.r, - ), - ), - ), - child: Text( - '완료', - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 16.sp, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ), - SizedBox(height: bottomInset), - ], + child: _buildDirectInputContent( + title: '반려동물의 종을 직접 입력해주세요.', + hintText: '예: 미어캣, 라쿤 등', + controller: speciesInputController, + bottomInset: bottomInset, + onComplete: () { + if (speciesInputController.text.isNotEmpty) { + setState(() { + _speciesController.text = + speciesInputController.text; + // 직접 입력 시 카테고리 정보 초기화 + _currentMajorCategory = null; + _currentMinorCategory = null; + _breedController.clear(); + }); + Navigator.pop(context); + } + }, ), ) : (selectedMajor == null @@ -1116,68 +1064,20 @@ class _PetFormScreenState extends State { child: showInput ? Padding( padding: EdgeInsets.all(20.w), - child: Column( - children: [ - Text( - '반려동물의 품종을 직접 입력해주세요.', - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 16.sp, - color: Colors.black87, - ), - ), - SizedBox(height: 20.h), - TextField( - controller: manualInputController, - autofocus: true, - decoration: const InputDecoration( - hintText: '예: 믹스, 시고르자브종 등', - border: OutlineInputBorder(), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: AppColors.highlight, - ), - ), - ), - ), - const Spacer(), - SizedBox( - width: double.infinity, - height: 52.h, - child: ElevatedButton( - onPressed: () { - if (manualInputController - .text - .isNotEmpty) { - setState(() { - _breedController.text = - manualInputController.text; - }); - Navigator.pop(context); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.highlight, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 12.r, - ), - ), - ), - child: Text( - '완료', - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 16.sp, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ), - SizedBox(height: bottomInset), - ], + child: _buildDirectInputContent( + title: '반려동물의 품종을 직접 입력해주세요.', + hintText: '예: 믹스, 시고르자브종 등', + controller: manualInputController, + bottomInset: bottomInset, + onComplete: () { + if (manualInputController.text.isNotEmpty) { + setState(() { + _breedController.text = + manualInputController.text; + }); + Navigator.pop(context); + } + }, ), ) : Column( @@ -1311,32 +1211,13 @@ class _PetFormScreenState extends State { ], ), SizedBox(height: 20.h), - Text( - '반려동물의 품종을 직접 입력해주세요.', - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 16.sp, - color: Colors.black87, - ), - ), - SizedBox(height: 20.h), - TextField( - controller: manualInputController, - autofocus: true, - decoration: const InputDecoration( + Expanded( + child: _buildDirectInputContent( + title: '반려동물의 품종을 직접 입력해주세요.', hintText: '예: 믹스, 시고르자브종 등', - border: OutlineInputBorder(), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: AppColors.highlight), - ), - ), - ), - const Spacer(), - SizedBox( - width: double.infinity, - height: 52.h, - child: ElevatedButton( - onPressed: () { + controller: manualInputController, + bottomInset: MediaQuery.of(context).viewInsets.bottom, + onComplete: () { if (manualInputController.text.isNotEmpty) { setState(() { _breedController.text = manualInputController.text; @@ -1344,25 +1225,8 @@ class _PetFormScreenState extends State { Navigator.pop(context); } }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.highlight, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.r), - ), - ), - child: Text( - '완료', - style: TextStyle( - fontFamily: 'SCDream', - fontSize: 16.sp, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), ), ), - SizedBox(height: MediaQuery.of(context).viewInsets.bottom), ], ), ), @@ -1595,25 +1459,7 @@ class _PetFormScreenState extends State { return Scaffold( backgroundColor: Colors.white, - appBar: AppBar( - title: Text( - isEditMode ? '반려동물 정보 수정' : '반려동물 등록', - style: TextStyle( - color: Color(0xFF1f1f1f), - fontFamily: 'SCDream', - fontWeight: FontWeight.w500, - fontSize: 15.sp, - ), - ), - centerTitle: true, - backgroundColor: Colors.white, - scrolledUnderElevation: 0, - elevation: 0, - leading: IconButton( - icon: Icon(Icons.arrow_back_ios, color: Colors.black, size: 16.w), - onPressed: () => Navigator.pop(context), - ), - ), + appBar: CustomAppBar(title: isEditMode ? '반려동물 정보 수정' : '반려동물 등록'), body: SingleChildScrollView( padding: EdgeInsets.all(20.w), child: Column( @@ -1878,15 +1724,10 @@ class _PetFormScreenState extends State { ); // Reusing logic // Helper logic copy - List displayList = selected - .where((e) => e != '기타') - .toList(); - if (selected.contains('기타') && otherText.isNotEmpty) { - displayList.add('기타($otherText)'); - } else if (selected.contains('기타')) { - displayList.add('기타'); - } - _diseaseController.text = displayList.join(', '); + _diseaseController.text = _generateDisplayString( + selected, + otherText, + ); }); }, ), @@ -1904,15 +1745,10 @@ class _PetFormScreenState extends State { setState(() { _selectedPastDiseases = selected; _otherPastDiseaseText = otherText; - List displayList = selected - .where((e) => e != '기타') - .toList(); - if (selected.contains('기타') && otherText.isNotEmpty) { - displayList.add('기타($otherText)'); - } else if (selected.contains('기타')) { - displayList.add('기타'); - } - _pastDiseaseController.text = displayList.join(', '); + _pastDiseaseController.text = _generateDisplayString( + selected, + otherText, + ); }); }, ), @@ -1930,15 +1766,10 @@ class _PetFormScreenState extends State { setState(() { _selectedHealthConcerns = selected; _otherHealthConcernText = otherText; - List displayList = selected - .where((e) => e != '기타') - .toList(); - if (selected.contains('기타') && otherText.isNotEmpty) { - displayList.add('기타($otherText)'); - } else if (selected.contains('기타')) { - displayList.add('기타'); - } - _healthConcernController.text = displayList.join(', '); + _healthConcernController.text = _generateDisplayString( + selected, + otherText, + ); }); }, ), @@ -2109,4 +1940,87 @@ class _PetFormScreenState extends State { ], ); } + + // Helper Widget: 직접 입력 UI (모달 내부 공통 사용) + Widget _buildDirectInputContent({ + required String title, + required String hintText, + required TextEditingController controller, + required VoidCallback onComplete, + required double bottomInset, + }) { + return Column( + children: [ + Text( + title, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 16.sp, + color: Colors.black87, + ), + ), + SizedBox(height: 20.h), + TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: hintText, + border: const OutlineInputBorder(), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: AppColors.highlight), + ), + ), + ), + const Spacer(), + SizedBox( + width: double.infinity, + height: 52.h, + child: ElevatedButton( + onPressed: onComplete, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.highlight, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + ), + child: Text( + '완료', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 16.sp, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + SizedBox(height: bottomInset), + ], + ); + } + + // 복잡한 질병 목록 처리 로직 (제출용) + List _processSelectionForSubmit( + List selected, + String otherText, + ) { + List finalList = List.from(selected); + if (finalList.contains('기타') && otherText.isNotEmpty) { + finalList.remove('기타'); + finalList.add('기타($otherText)'); + } + return finalList; + } + + // 화면 표시용 텍스트 생성 Helper + String _generateDisplayString(List selected, String otherText) { + List displayList = selected.where((e) => e != '기타').toList(); + if (selected.contains('기타') && otherText.isNotEmpty) { + displayList.add('기타($otherText)'); + } else if (selected.contains('기타')) { + displayList.add('기타'); + } + return displayList.join(', '); + } } diff --git a/app/lib/screens/pet_registration_screen.dart b/app/lib/screens/pet_registration_screen.dart index fc4687d..3d47b10 100644 --- a/app/lib/screens/pet_registration_screen.dart +++ b/app/lib/screens/pet_registration_screen.dart @@ -10,6 +10,7 @@ import '../widgets/pet_registration/selection_modal.dart'; // Import SelectionMo import '../widgets/pet_registration/input_formatters.dart'; // Import InputFormatters import '../services/firestore_service.dart'; import '../models/pet_model.dart'; +import '../widgets/common/custom_app_bar.dart'; class PetRegistrationScreen extends StatefulWidget { const PetRegistrationScreen({super.key}); @@ -1387,25 +1388,7 @@ class _PetRegistrationScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - appBar: AppBar( - title: Text( - '반려동물 등록', - style: TextStyle( - color: Color(0xFF1f1f1f), - fontFamily: 'SCDream', - fontWeight: FontWeight.w500, - fontSize: 15.sp, - ), - ), - centerTitle: true, - backgroundColor: Colors.white, - scrolledUnderElevation: 0, - elevation: 0, - leading: IconButton( - icon: Icon(Icons.arrow_back_ios, color: Colors.black, size: 16.w), - onPressed: () => Navigator.pop(context), - ), - ), + appBar: const CustomAppBar(title: '반려동물 등록'), body: SingleChildScrollView( padding: EdgeInsets.all(20.w), child: Column( diff --git a/app/lib/screens/schedule_form_screen.dart b/app/lib/screens/schedule_form_screen.dart new file mode 100644 index 0000000..d022494 --- /dev/null +++ b/app/lib/screens/schedule_form_screen.dart @@ -0,0 +1,1040 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:provider/provider.dart'; + +import '../providers/pet_provider.dart'; +import '../services/api_service.dart'; +import '../theme/app_colors.dart'; + +import '../widgets/common/custom_app_bar.dart'; +import '../widgets/common/custom_time_picker.dart'; + +class ScheduleFormScreen extends StatefulWidget { + final DateTime selectedDate; + + const ScheduleFormScreen({super.key, required this.selectedDate}); + + @override + State createState() => _ScheduleFormScreenState(); +} + +class _ScheduleFormScreenState extends State { + final TextEditingController _titleController = TextEditingController(); + final TextEditingController _noteController = TextEditingController(); + + // 시간 설정 상태 + late TimeOfDay _startTime; + late TimeOfDay _endTime; + bool _isEndTimeManuallySet = false; // 사용자가 종료시간을 직접 수정했는지 여부 + String? _activePicker; // 'start', 'end', or null + + String _selectedType = 'general'; // 'general' or 'important' + bool _isSubstituting = false; // "저장 중" 상태 표시 + + // 반복 설정 상태 + bool _isRepeatExpanded = false; + final TextEditingController _repeatIntervalController = TextEditingController( + text: '1', + ); + String? _repeatUnit; // null, 'day', 'week', 'month', 'year' + // 알림 설정 상태 + bool _isAlarmExpanded = false; + String _selectedAlarmType = + 'none'; // 'none', 'start', '10min', '1hour', '1day', 'custom' + String _customAlarmUnit = 'min'; // 'min', 'hour', 'day', 'week' + final TextEditingController _customAlarmController = TextEditingController(); + + @override + void initState() { + super.initState(); + // 초기 시간 설정: 시작 08:00, 종료 09:00 + _startTime = const TimeOfDay(hour: 8, minute: 0); + _endTime = const TimeOfDay(hour: 9, minute: 0); + } + + @override + void dispose() { + _titleController.dispose(); + _noteController.dispose(); + _repeatIntervalController.dispose(); + _customAlarmController.dispose(); + super.dispose(); + } + + // 시작 시간 변경 핸들러 + void _onStartTimeChanged(TimeOfDay newTime) { + setState(() { + _startTime = newTime; + // 종료 시간을 수동으로 설정하지 않았다면, 자동으로 +1시간 + if (!_isEndTimeManuallySet) { + int endHour = newTime.hour + 1; + int endMinute = newTime.minute; + // 24시 넘어가면 0시로 (단순화) + if (endHour >= 24) { + endHour -= 24; + } + _endTime = TimeOfDay(hour: endHour, minute: endMinute); + } + }); + } + + // 종료 시간 변경 핸들러 + void _onEndTimeChanged(TimeOfDay newTime) { + setState(() { + _endTime = newTime; + _isEndTimeManuallySet = true; // 수동 설정됨 -> 자동 연동 해제 + }); + } + + String _formatTime(TimeOfDay time) { + final period = time.period == DayPeriod.am ? '오전' : '오후'; + final hour = time.hourOfPeriod == 0 ? 12 : time.hourOfPeriod; + final minute = time.minute.toString().padLeft(2, '0'); + return '$period $hour:$minute'; + } + + String _getRepeatText() { + if (_repeatUnit == null) return '반복 안 함'; + final interval = _repeatIntervalController.text; + String unitText = ''; + switch (_repeatUnit) { + case 'day': + unitText = '일'; + break; + case 'week': + unitText = '주'; + break; + case 'month': + unitText = '개월'; + break; + case 'year': + unitText = '년'; + break; + } + return '$interval$unitText마다'; + } + + Future _saveSchedule() async { + final title = _titleController.text.trim(); + if (title.isEmpty) return; + + final petProvider = context.read(); + final selectedPet = petProvider.selectedPet; + + if (selectedPet == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('반려동물을 먼저 선택해주세요.'))); + return; + } + + setState(() { + _isSubstituting = true; + }); + + try { + final apiService = ApiService(); + + // 시작/종료 시간 DateTime 생성 + final startDateTime = DateTime( + widget.selectedDate.year, + widget.selectedDate.month, + widget.selectedDate.day, + _startTime.hour, + _startTime.minute, + ); + + // 반복 정보 + int? repeatInterval; + if (_repeatUnit != null) { + repeatInterval = int.tryParse(_repeatIntervalController.text) ?? 1; + } + + // 알림 시간 계산 + DateTime? alarmTime; + bool isAlarmOn = _selectedAlarmType != 'none'; + if (isAlarmOn) { + switch (_selectedAlarmType) { + case 'start': + alarmTime = startDateTime; + break; + case '10min': + alarmTime = startDateTime.subtract(const Duration(minutes: 10)); + break; + case '1hour': + alarmTime = startDateTime.subtract(const Duration(hours: 1)); + break; + case '1day': + alarmTime = startDateTime.subtract(const Duration(days: 1)); + break; + case 'custom': + final value = int.tryParse(_customAlarmController.text) ?? 0; + if (value > 0) { + Duration duration; + switch (_customAlarmUnit) { + case 'min': + duration = Duration(minutes: value); + break; + case 'hour': + duration = Duration(hours: value); + break; + case 'day': + duration = Duration(days: value); + break; + case 'week': + duration = Duration(days: value * 7); + break; + default: + duration = Duration(minutes: value); + } + alarmTime = startDateTime.subtract(duration); + } else { + alarmTime = startDateTime; + } + break; + } + } + + final data = { + 'petId': selectedPet.id, + 'date': startDateTime.toIso8601String(), + 'type': _selectedType, + 'isCompleted': false, + 'title': title, + 'note': _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + 'repeatInterval': repeatInterval, + 'repeatUnit': _repeatUnit, + 'isAlarmOn': isAlarmOn, + 'alarmTime': alarmTime?.toIso8601String(), + }; + + await apiService.createSchedule(data); + + if (mounted) { + context + .read() + .refreshPets(); // Optional: Refresh if needed + Navigator.pop(context, true); // 성공 시 true 반환 + } + } catch (e) { + debugPrint('Error saving schedule: $e'); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('일정 저장 실패: $e'))); + } + } finally { + if (mounted) { + setState(() { + _isSubstituting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: const CustomAppBar(title: '일정 추가'), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 20.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 제목 입력 + TextField( + onTap: () { + setState(() { + _activePicker = null; + _isRepeatExpanded = false; + _isAlarmExpanded = false; + }); + }, + controller: _titleController, + decoration: InputDecoration( + labelText: '일정 제목', + floatingLabelStyle: const TextStyle( + color: AppColors.highlight, + ), + hintText: '예: 산책하기, 병원 방문', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide( + color: AppColors.highlight, + ), + ), + filled: false, + ), + ), + SizedBox(height: 24.h), + + // 시작 시간 / 종료 시간 (좌우 배치) + Row( + children: [ + // 시작 시간 + Expanded( + child: _buildTimeField( + label: '시작', + type: 'start', + time: _startTime, + ), + ), + SizedBox(width: 12.w), + // 종료 시간 + Expanded( + child: _buildTimeField( + label: '종료', + type: 'end', + time: _endTime, + ), + ), + ], + ), + + // 피커 영역 (아코디언) + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: Container( + // 피커가 열렸을 때만 공간 차지 + height: _activePicker != null ? null : 0, + margin: _activePicker != null + ? EdgeInsets.symmetric(vertical: 10.h) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: Colors.grey[200]!), + ), + child: _activePicker != null + ? CustomTimePicker( + initialTime: _activePicker == 'start' + ? _startTime + : _endTime, + onTimeChanged: (newTime) { + if (_activePicker == 'start') { + _onStartTimeChanged(newTime); + } else { + _onEndTimeChanged(newTime); + } + }, + ) + : const SizedBox.shrink(), + ), + ), + + SizedBox(height: _activePicker != null ? 0 : 24.h), + + // 중요도 선택 + Row( + children: [ + Expanded( + child: _buildTypeButton( + '일반', + 'general', + AppColors.subHighlight, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: _buildTypeButton( + '중요', + 'important', + AppColors.highlight, + ), + ), + ], + ), + SizedBox(height: 16.h), + + // 반복 설정 + _buildRepeatSection(), + SizedBox(height: 16.h), + + // 알림 설정 + _buildAlarmSection(), + SizedBox(height: 16.h), + + // 메모 입력 + TextField( + onTap: () { + setState(() { + _activePicker = null; + _isRepeatExpanded = false; + _isAlarmExpanded = false; + }); + }, + controller: _noteController, + maxLines: 3, + decoration: InputDecoration( + labelText: '메모 (선택)', + floatingLabelStyle: const TextStyle( + color: AppColors.highlight, + ), + hintText: '추가적인 내용을 입력하세요', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide( + color: AppColors.highlight, + ), + ), + filled: false, + ), + ), + SizedBox(height: 24.h), + ], + ), + ), + ), + ], + ), + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: EdgeInsets.all(20.w), + child: ElevatedButton( + onPressed: _isSubstituting ? null : _saveSchedule, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.highlight, + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + elevation: 0, + ), + child: _isSubstituting + ? 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, + ), + ), + ), + ), + ), + ); + } + + String _getAlarmText() { + switch (_selectedAlarmType) { + case 'none': + return '사용 안함'; + case 'start': + return '일정 시작시간'; + case '10min': + return '10분 전'; + case '1hour': + return '1시간 전'; + case '1day': + return '1일 전'; + case 'custom': + final value = _customAlarmController.text; + if (value.isEmpty) return '직접 입력'; + String unitText = ''; + switch (_customAlarmUnit) { + case 'min': + unitText = '분 전'; + break; + case 'hour': + unitText = '시간 전'; + break; + case 'day': + unitText = '일 전'; + break; + case 'week': + unitText = '주 전'; + break; + } + return '$value$unitText'; + default: + return '사용 안함'; + } + } + + Widget _buildAlarmSection() { + final borderColor = _isAlarmExpanded + ? AppColors.highlight + : Colors.grey[300]!; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: borderColor, width: _isAlarmExpanded ? 2 : 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + onTap: () { + setState(() { + _isAlarmExpanded = !_isAlarmExpanded; + if (_isAlarmExpanded) { + _isRepeatExpanded = false; // 다른 섹션 닫기 + _activePicker = null; // 시간 설정 닫기 + } + }); + }, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12.r), + topRight: Radius.circular(12.r), + bottomLeft: Radius.circular(_isAlarmExpanded ? 0 : 12.r), + bottomRight: Radius.circular(_isAlarmExpanded ? 0 : 12.r), + ), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 16.w), + child: Row( + children: [ + Text( + '알림', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 12.sp, + color: Colors.grey[600], + ), + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + _getAlarmText(), + textAlign: TextAlign.right, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 14.sp, + fontWeight: FontWeight.bold, + color: _selectedAlarmType != 'none' + ? AppColors.highlight + : Colors.black, + ), + ), + ), + SizedBox(width: 8.w), + Icon( + _isAlarmExpanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + color: Colors.grey[400], + size: 20.w, + ), + ], + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: Container( + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + height: _isAlarmExpanded ? null : 0, + padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Row 1: 사용안함, 시작시간 (위치 변경됨) + Row( + children: [ + Expanded(child: _buildAlarmOptionButton('사용 안함', 'none')), + SizedBox(width: 8.w), + Expanded( + child: _buildAlarmOptionButton('일정 시작시간', 'start'), + ), + ], + ), + SizedBox(height: 8.h), + // Row 2: 10분, 1시간, 1일 + Row( + children: [ + Expanded( + child: _buildAlarmOptionButton('10분 전', '10min'), + ), + SizedBox(width: 8.w), + Expanded( + child: _buildAlarmOptionButton('1시간 전', '1hour'), + ), + SizedBox(width: 8.w), + Expanded(child: _buildAlarmOptionButton('1일 전', '1day')), + ], + ), + SizedBox(height: 8.h), + // Row 3: 직접입력 버튼 + 입력칸 + Row( + children: [ + Expanded( + child: _buildAlarmOptionButton('직접 입력', 'custom'), + ), + SizedBox(width: 8.w), + Expanded( + child: Container( + height: 34.h, // 버튼 높이와 비슷하게 조정 (48.h -> 34.h) + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(20.r), + color: _selectedAlarmType == 'custom' + ? Colors.white + : Colors.grey[100], + ), + alignment: Alignment.center, + child: TextField( + controller: _customAlarmController, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + onTap: () { + setState(() { + _selectedAlarmType = 'custom'; + }); + }, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 16.sp, + fontWeight: _selectedAlarmType == 'custom' + ? FontWeight.bold + : FontWeight.normal, + color: _selectedAlarmType == 'custom' + ? Colors.black + : Colors.grey[400], + height: 1.0, + ), + decoration: InputDecoration( + border: InputBorder.none, + isCollapsed: true, + contentPadding: EdgeInsets.zero, + hintText: '0', + hintStyle: TextStyle( + color: Colors.grey[400], + fontSize: 16.sp, + ), + ), + ), + ), + ), + ], + ), + // Row 4: 단위 버튼 (custom일 때만 표시) + if (_selectedAlarmType == 'custom') ...[ + SizedBox(height: 12.h), + Row( + children: [ + Expanded(child: _buildCustomUnitButton('분 전', 'min')), + SizedBox(width: 8.w), + Expanded(child: _buildCustomUnitButton('시간 전', 'hour')), + SizedBox(width: 8.w), + Expanded(child: _buildCustomUnitButton('일 전', 'day')), + SizedBox(width: 8.w), + Expanded(child: _buildCustomUnitButton('주 전', 'week')), + ], + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildCustomUnitButton(String label, String unit) { + final isSelected = _customAlarmUnit == unit; + return InkWell( + onTap: () { + setState(() { + _customAlarmUnit = unit; + }); + }, + borderRadius: BorderRadius.circular(20.r), + child: Container( + width: double.infinity, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(vertical: 8.h), + decoration: BoxDecoration( + color: isSelected ? AppColors.highlight : Colors.grey[100], + borderRadius: BorderRadius.circular(20.r), + ), + child: Text( + label, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 13.sp, + color: isSelected ? Colors.white : Colors.grey[600], + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } + + Widget _buildAlarmOptionButton(String label, String value) { + final isSelected = _selectedAlarmType == value; + return InkWell( + onTap: () { + setState(() { + _selectedAlarmType = value; + if (value == 'custom') { + // Focus custom input? + } + }); + }, + borderRadius: BorderRadius.circular(20.r), + child: Container( + width: double.infinity, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(vertical: 8.h), + decoration: BoxDecoration( + color: isSelected ? AppColors.highlight : Colors.grey[100], + borderRadius: BorderRadius.circular(20.r), + ), + child: Text( + label, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 13.sp, + color: isSelected ? Colors.white : Colors.grey[600], + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } + + Widget _buildRepeatSection() { + final borderColor = _isRepeatExpanded + ? AppColors.highlight + : Colors.grey[300]!; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: borderColor, + width: _isRepeatExpanded ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + onTap: () { + setState(() { + _isRepeatExpanded = !_isRepeatExpanded; + if (_isRepeatExpanded) { + _isAlarmExpanded = false; // 다른 섹션 닫기 + _activePicker = null; // 시간 설정 닫기 + } + }); + }, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12.r), + topRight: Radius.circular(12.r), + bottomLeft: Radius.circular(_isRepeatExpanded ? 0 : 12.r), + bottomRight: Radius.circular(_isRepeatExpanded ? 0 : 12.r), + ), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 16.w), + child: Row( + children: [ + Text( + '반복', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 12.sp, + color: Colors.grey[600], + ), + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + _getRepeatText(), + textAlign: TextAlign.right, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 14.sp, + fontWeight: FontWeight.bold, + color: _repeatUnit != null + ? AppColors.highlight + : Colors.black, + ), + ), + ), + SizedBox(width: 8.w), + Icon( + _isRepeatExpanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + color: Colors.grey[400], + size: 20.w, + ), + ], + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: Container( + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + height: _isRepeatExpanded ? null : 0, + padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h), + child: Column( + children: [ + Row( + children: [ + Container( + width: 60.w, + height: 40.h, + margin: EdgeInsets.only(right: 12.w), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(8.r), + ), + alignment: Alignment.center, + child: TextField( + controller: _repeatIntervalController, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 16.sp, + fontWeight: FontWeight.bold, + height: 1.0, + ), + decoration: const InputDecoration( + border: InputBorder.none, + isCollapsed: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: (value) { + if (value == '0') { + _repeatIntervalController.text = '1'; + } + setState(() {}); + }, + ), + ), + // 단위 선택 버튼들 + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + _buildRepeatUnitButton('일', 'day'), + SizedBox(width: 8.w), + _buildRepeatUnitButton('주', 'week'), + SizedBox(width: 8.w), + _buildRepeatUnitButton('개월', 'month'), + SizedBox(width: 8.w), + _buildRepeatUnitButton('년', 'year'), + ], + ), + ), + ), + ], + ), + SizedBox(height: 12.h), + // 반복 안 함 버튼 (하단 배치) + InkWell( + onTap: () { + setState(() { + _repeatUnit = null; + }); + }, + borderRadius: BorderRadius.circular(8.r), + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 10.h), + decoration: BoxDecoration( + color: _repeatUnit == null + ? AppColors.highlight + : Colors.grey[100], + borderRadius: BorderRadius.circular(8.r), + ), + alignment: Alignment.center, + child: Text( + '반복 안 함', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 14.sp, + color: _repeatUnit == null + ? Colors.white + : Colors.grey[600], + fontWeight: _repeatUnit == null + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildRepeatUnitButton(String label, String? unit) { + final isSelected = _repeatUnit == unit; + return InkWell( + onTap: () { + setState(() { + _repeatUnit = unit; + }); + }, + borderRadius: BorderRadius.circular(20.r), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + decoration: BoxDecoration( + color: isSelected ? AppColors.highlight : Colors.grey[100], + borderRadius: BorderRadius.circular(20.r), + ), + child: Text( + label, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 14.sp, + color: isSelected ? Colors.white : Colors.grey[600], + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } + + Widget _buildTimeField({ + required String label, + required String type, + required TimeOfDay time, + }) { + final isActive = _activePicker == type; + final borderColor = isActive ? AppColors.highlight : Colors.grey[300]!; + final textColor = isActive ? AppColors.highlight : Colors.black; + + return GestureDetector( + onTap: () { + setState(() { + if (_activePicker == type) { + _activePicker = null; // 이미 열려있으면 닫기 + } else { + _activePicker = type; // 열기 + _isRepeatExpanded = false; // 다른 섹션 닫기 + _isAlarmExpanded = false; // 다른 섹션 닫기 + } + }); + }, + child: Container( + padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 16.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: borderColor, width: isActive ? 2 : 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 12.sp, + color: Colors.grey[600], + ), + ), + SizedBox(height: 4.h), + Text( + _formatTime(time), + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 18.sp, + fontWeight: FontWeight.bold, + color: textColor, + ), + ), + ], + ), + ), + ); + } + + Widget _buildTypeButton(String label, String value, Color color) { + final isSelected = _selectedType == value; + return InkWell( + onTap: () { + setState(() { + _selectedType = value; + _activePicker = null; + _isRepeatExpanded = false; + _isAlarmExpanded = false; + }); + }, + borderRadius: BorderRadius.circular(12.r), + child: Container( + padding: EdgeInsets.symmetric(vertical: 12.h), + decoration: BoxDecoration( + color: isSelected ? color.withOpacity(0.1) : Colors.transparent, + border: Border.all( + color: isSelected ? color : Colors.grey[300]!, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12.r), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 12.w, + height: 12.w, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + SizedBox(width: 8.w), + Text( + label, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 14.sp, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? color : Colors.grey[600], + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/screens/terms_agreement_screen.dart b/app/lib/screens/terms_agreement_screen.dart index 46e1a23..dd2226b 100644 --- a/app/lib/screens/terms_agreement_screen.dart +++ b/app/lib/screens/terms_agreement_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import screenuti import 'identity_verification_screen.dart'; import '../services/auth_service.dart'; // Import AuthService import '../data/terms_data.dart'; +import '../widgets/common/custom_app_bar.dart'; class TermsAgreementScreen extends StatefulWidget { final bool isViewOnly; @@ -139,24 +140,7 @@ class _TermsAgreementScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - icon: Icon(Icons.arrow_back_ios, color: Colors.black, size: 20.w), - onPressed: () => Navigator.pop(context), - ), - title: Text( - widget.isViewOnly ? '이용 약관' : '회원가입', - style: TextStyle( - color: Colors.black, - fontSize: 16.sp, - fontWeight: FontWeight.w600, - fontFamily: 'SCDream', - ), - ), - centerTitle: true, - ), + appBar: CustomAppBar(title: widget.isViewOnly ? '이용 약관' : '회원가입'), body: SafeArea( child: Column( children: [ diff --git a/app/lib/services/api_service.dart b/app/lib/services/api_service.dart index 5db38ef..86f7bb9 100644 --- a/app/lib/services/api_service.dart +++ b/app/lib/services/api_service.dart @@ -46,28 +46,22 @@ class ApiService { List? healthConcerns, }) async { try { - final formData = FormData.fromMap({ - 'userId': userId, - 'name': name, - 'species': species, - 'breed': breed, - 'gender': gender, // "남아", "여아", "기타" - 'isNeutered': isNeutered, - 'birthDate': birthDate?.toIso8601String(), - 'isDateUnknown': isDateUnknown, - 'weight': weight, - 'registrationNumber': registrationNumber, - 'diseases': diseases != null ? jsonEncode(diseases) : '[]', - 'pastDiseases': pastDiseases != null ? jsonEncode(pastDiseases) : '[]', - 'healthConcerns': healthConcerns != null - ? jsonEncode(healthConcerns) - : '[]', - if (profileImage != null) - 'profileImage': await MultipartFile.fromFile( - profileImage.path, - filename: profileImage.path.split('/').last, - ), - }); + final formData = await _createPetFormData( + userId: userId, + name: name, + species: species, + breed: breed, + gender: gender, + isNeutered: isNeutered, + birthDate: birthDate, + isDateUnknown: isDateUnknown, + weight: weight, + registrationNumber: registrationNumber, + profileImage: profileImage, + diseases: diseases, + pastDiseases: pastDiseases, + healthConcerns: healthConcerns, + ); final response = await _dio.post('/pets', data: formData); return response.data; @@ -77,7 +71,6 @@ class ApiService { } } - // Update Pet Future> updatePet({ required int petId, required String name, @@ -95,27 +88,21 @@ class ApiService { List? healthConcerns, }) async { try { - final formData = FormData.fromMap({ - 'name': name, - 'species': species, - 'breed': breed, - 'gender': gender, - 'isNeutered': isNeutered, - 'birthDate': birthDate?.toIso8601String(), - 'isDateUnknown': isDateUnknown, - 'weight': weight, - 'registrationNumber': registrationNumber, - 'diseases': diseases != null ? jsonEncode(diseases) : '[]', - 'pastDiseases': pastDiseases != null ? jsonEncode(pastDiseases) : '[]', - 'healthConcerns': healthConcerns != null - ? jsonEncode(healthConcerns) - : '[]', - if (profileImage != null) - 'profileImage': await MultipartFile.fromFile( - profileImage.path, - filename: profileImage.path.split('/').last, - ), - }); + final formData = await _createPetFormData( + name: name, + species: species, + breed: breed, + gender: gender, + isNeutered: isNeutered, + birthDate: birthDate, + isDateUnknown: isDateUnknown, + weight: weight, + registrationNumber: registrationNumber, + profileImage: profileImage, + diseases: diseases, + pastDiseases: pastDiseases, + healthConcerns: healthConcerns, + ); final response = await _dio.put('/pets/$petId', data: formData); return response.data; @@ -125,6 +112,51 @@ class ApiService { } } + // Helper: Create FormData + Future _createPetFormData({ + required String name, + required String species, + required String breed, + required String gender, + required bool isNeutered, + DateTime? birthDate, + required bool isDateUnknown, + double? weight, + String? registrationNumber, + File? profileImage, + List? diseases, + List? pastDiseases, + List? healthConcerns, + int? userId, // Optional (only for register) + }) async { + final map = { + if (userId != null) '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) { + map['profileImage'] = await MultipartFile.fromFile( + profileImage.path, + filename: profileImage.path.split('/').last, + ); + } + + return FormData.fromMap(map); + } + // Get Pets Future> getPets(int userId) async { try { @@ -138,4 +170,59 @@ class ApiService { rethrow; } } + + // --- Schedule APIs --- + + // Get Monthly Schedules + Future> getSchedules({ + required String petId, + required int year, + required int month, + }) async { + try { + final response = await _dio.get( + '/schedules', + queryParameters: {'petId': petId, 'year': year, 'month': month}, + ); + return response.data; + } catch (e) { + debugPrint('Error fetching schedules: $e'); + rethrow; + } + } + + // Create Schedule + Future> createSchedule(Map data) async { + try { + final response = await _dio.post('/schedules', data: data); + return response.data; + } catch (e) { + debugPrint('Error creating schedule: $e'); + rethrow; + } + } + + // Update Schedule + Future> updateSchedule( + String id, + Map data, + ) async { + try { + final response = await _dio.put('/schedules/$id', data: data); + return response.data; + } catch (e) { + debugPrint('Error updating schedule: $e'); + rethrow; + } + } + + // Delete Schedule + Future deleteSchedule(String id) async { + try { + await _dio.delete('/schedules/$id'); + } catch (e) { + debugPrint('Error deleting schedule: $e'); + rethrow; + } + } } diff --git a/app/lib/services/firestore_service.dart b/app/lib/services/firestore_service.dart index 883ae3b..a13e3f9 100644 --- a/app/lib/services/firestore_service.dart +++ b/app/lib/services/firestore_service.dart @@ -4,6 +4,7 @@ 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 '../models/schedule_model.dart'; import '../utils/log_manager.dart'; class FirestoreService { @@ -147,4 +148,79 @@ class FirestoreService { String generatePetId() { return const Uuid().v4(); } + + // 일정 추가 + Future addSchedule(Schedule schedule) async { + try { + await _db.collection('schedules').doc(schedule.id).set(schedule.toMap()); + } catch (e) { + LogManager().addLog('[Firestore] Error adding schedule: $e'); + throw Exception('일정 추가 실패: $e'); + } + } + + // 일정 조회 (특정 반려동물, 특정 날짜) + // 날짜는 년,월,일 만 비교를 위해 range query 사용 (startOfDay ~ endOfDay) + Stream> getSchedules(String petId, DateTime date) { + DateTime startOfDay = DateTime(date.year, date.month, date.day); + DateTime endOfDay = DateTime(date.year, date.month, date.day, 23, 59, 59); + + return _db + .collection('schedules') + .where('petId', isEqualTo: petId) + .where('date', isGreaterThanOrEqualTo: Timestamp.fromDate(startOfDay)) + .where('date', isLessThanOrEqualTo: Timestamp.fromDate(endOfDay)) + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => Schedule.fromMap(doc.data())) + .toList(); + }); + } + + // 월간 일정 조회 (도장 모드용) - 해당 월의 모든 일정 가져오기 + Stream> getMonthlySchedules(String petId, DateTime month) { + DateTime startOfMonth = DateTime(month.year, month.month, 1); + DateTime endOfMonth = DateTime(month.year, month.month + 1, 0, 23, 59, 59); + + return _db + .collection('schedules') + .where('petId', isEqualTo: petId) + .where('date', isGreaterThanOrEqualTo: Timestamp.fromDate(startOfMonth)) + .where('date', isLessThanOrEqualTo: Timestamp.fromDate(endOfMonth)) + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => Schedule.fromMap(doc.data())) + .toList(); + }); + } + + // 일정 수정 + Future updateSchedule(Schedule schedule) async { + try { + await _db + .collection('schedules') + .doc(schedule.id) + .update(schedule.toMap()); + } catch (e) { + LogManager().addLog('[Firestore] Error updating schedule: $e'); + throw Exception('일정 수정 실패: $e'); + } + } + + // 일정 삭제 + Future deleteSchedule(String scheduleId) async { + try { + await _db.collection('schedules').doc(scheduleId).delete(); + } catch (e) { + LogManager().addLog('[Firestore] Error deleting schedule: $e'); + throw Exception('일정 삭제 실패: $e'); + } + } + + // ID 생성 + String generateScheduleId() { + return const Uuid().v4(); + } } diff --git a/app/lib/widgets/common/custom_app_bar.dart b/app/lib/widgets/common/custom_app_bar.dart new file mode 100644 index 0000000..34a155a --- /dev/null +++ b/app/lib/widgets/common/custom_app_bar.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { + final String title; + final Widget? leading; + final List? actions; + final VoidCallback? onLeadingPressed; + final bool showBottomBorder; + + const CustomAppBar({ + super.key, + required this.title, + this.leading, + this.actions, + this.onLeadingPressed, + this.showBottomBorder = true, + }); + + @override + Widget build(BuildContext context) { + return AppBar( + backgroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + title: Text( + title, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 18.sp, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + leading: + leading ?? + IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black), + onPressed: onLeadingPressed ?? () => Navigator.pop(context), + ), + actions: actions, + bottom: showBottomBorder + ? PreferredSize( + preferredSize: Size.fromHeight(1.h), + child: Divider( + color: const Color(0xFFEEEEEE), + thickness: 1.h, + height: 1.h, + ), + ) + : null, + ); + } + + @override + Size get preferredSize => + Size.fromHeight(kToolbarHeight + (showBottomBorder ? 1.h : 0)); +} diff --git a/app/lib/widgets/common/custom_time_picker.dart b/app/lib/widgets/common/custom_time_picker.dart new file mode 100644 index 0000000..ee7b842 --- /dev/null +++ b/app/lib/widgets/common/custom_time_picker.dart @@ -0,0 +1,366 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class CustomTimePicker extends StatefulWidget { + final TimeOfDay initialTime; + final ValueChanged onTimeChanged; + + const CustomTimePicker({ + super.key, + required this.initialTime, + required this.onTimeChanged, + }); + + @override + State createState() => _CustomTimePickerState(); +} + +class _CustomTimePickerState extends State { + late FixedExtentScrollController _amPmController; + late FixedExtentScrollController _hourController; + late FixedExtentScrollController _minuteController; + + // 시간 직접 입력을 위한 컨트롤러 + final TextEditingController _hourInputController = TextEditingController(); + final TextEditingController _minuteInputController = TextEditingController(); + + // 입력 모드 상태 관리 + bool _isEditingHour = false; + bool _isEditingMinute = false; + final FocusNode _hourFocusNode = FocusNode(); + final FocusNode _minuteFocusNode = FocusNode(); + + // 내부 상태 (부모와 동기화) + late int _selectedAmPm; // 0: AM, 1: PM + late int _selectedHour; // 1~12 + late int _selectedMinute; // 0~59 + + // 외부 업데이트 중인지 확인하는 플래그 + bool _isSyncing = false; + + @override + void initState() { + super.initState(); + _initializeState(); + } + + @override + void didUpdateWidget(CustomTimePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialTime != widget.initialTime) { + _isSyncing = true; // 동기화 시작 + try { + _updateInternalStateFromWidget(); + + // jumpToItem은 onSelectedItemChanged를 트리거할 수도 있으므로(구현에 따라 다름) + // 플래그로 콜백 호출을 막습니다. + if (_amPmController.hasClients) { + _amPmController.jumpToItem(_selectedAmPm); + } + if (_hourController.hasClients) { + _hourController.jumpToItem(_selectedHour - 1); + } + if (_minuteController.hasClients) { + _minuteController.jumpToItem(_selectedMinute); + } + } finally { + _isSyncing = false; // 동기화 종료 + } + } + } + + void _initializeState() { + _updateInternalStateFromWidget(); + + _amPmController = FixedExtentScrollController(initialItem: _selectedAmPm); + _hourController = FixedExtentScrollController( + initialItem: _selectedHour - 1, + ); + _minuteController = FixedExtentScrollController( + initialItem: _selectedMinute, + ); + } + + void _updateInternalStateFromWidget() { + final t = widget.initialTime; + _selectedAmPm = t.period == DayPeriod.pm ? 1 : 0; + _selectedHour = t.hourOfPeriod == 0 ? 12 : t.hourOfPeriod; + _selectedMinute = t.minute; + + if (!_isEditingHour) { + _hourInputController.text = _selectedHour.toString(); + } + if (!_isEditingMinute) { + _minuteInputController.text = _selectedMinute.toString().padLeft(2, '0'); + } + } + + void _notifyTimeChanged() { + if (_isSyncing) return; // 외부 동기화 중이면 부모에게 알리지 않음 + + // 내부 상태 -> TimeOfDay 변환 후 콜백 호출 + int hour24 = _selectedHour; + if (_selectedAmPm == 1 && _selectedHour < 12) { + hour24 += 12; + } else if (_selectedAmPm == 0 && _selectedHour == 12) { + hour24 = 0; + } + widget.onTimeChanged(TimeOfDay(hour: hour24, minute: _selectedMinute)); + } + + @override + void dispose() { + _amPmController.dispose(); + _hourController.dispose(); + _minuteController.dispose(); + _hourInputController.dispose(); + _minuteInputController.dispose(); + _hourFocusNode.dispose(); + _minuteFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 150.h, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 오전/오후 + _buildWheelPicker( + controller: _amPmController, + items: ['오전', '오후'], + onChanged: (index) { + if (_isSyncing) return; + setState(() { + _selectedAmPm = index; + }); + _notifyTimeChanged(); + }, + width: 60.w, + height: 150.h, + selectedIndex: _selectedAmPm, + ), + + // 시 + _buildWheelPicker( + controller: _hourController, + items: List.generate(12, (index) => (index + 1).toString()), + onChanged: (index) { + if (_isSyncing) return; + setState(() { + _selectedHour = index + 1; + if (!_isEditingHour) { + _hourInputController.text = _selectedHour.toString(); + } + }); + _notifyTimeChanged(); + }, + width: 60.w, + height: 150.h, + isNumber: true, + inputController: _hourInputController, + isEditing: _isEditingHour, + focusNode: _hourFocusNode, + selectedIndex: _selectedHour - 1, + onTap: () { + setState(() { + _isEditingHour = true; + _isEditingMinute = false; + }); + _hourFocusNode.requestFocus(); + }, + onEditingComplete: () { + setState(() => _isEditingHour = false); + }, + onInputChanged: (value) { + if (value.isNotEmpty) { + int? hour = int.tryParse(value); + if (hour != null && hour >= 1 && hour <= 12) { + setState(() => _selectedHour = hour); + if (_hourController.hasClients) { + _hourController.jumpToItem(hour - 1); + } + _notifyTimeChanged(); + } + + // Auto-focus logic + if (value.length == 2) { + setState(() { + _isEditingHour = false; + _isEditingMinute = true; + }); + _minuteFocusNode.requestFocus(); + } + } + }, + ), + + Container( + height: 150.h, + alignment: Alignment.center, + child: Text( + ':', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 24.sp, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ), + + // 분 + _buildWheelPicker( + controller: _minuteController, + items: List.generate( + 60, + (index) => index.toString().padLeft(2, '0'), + ), + onChanged: (index) { + if (_isSyncing) return; + setState(() { + _selectedMinute = index; + if (!_isEditingMinute) { + _minuteInputController.text = _selectedMinute + .toString() + .padLeft(2, '0'); + } + }); + _notifyTimeChanged(); + }, + width: 60.w, + height: 150.h, + isNumber: true, + inputController: _minuteInputController, + isEditing: _isEditingMinute, + focusNode: _minuteFocusNode, + selectedIndex: _selectedMinute, + onTap: () { + setState(() { + _isEditingMinute = true; + _isEditingHour = false; + }); + _minuteFocusNode.requestFocus(); + }, + onEditingComplete: () { + setState(() => _isEditingMinute = false); + }, + onInputChanged: (value) { + if (value.isNotEmpty) { + int? minute = int.tryParse(value); + if (minute != null && minute >= 0 && minute <= 59) { + setState(() => _selectedMinute = minute); + if (_minuteController.hasClients) { + _minuteController.jumpToItem(minute); + } + _notifyTimeChanged(); + } + } + }, + ), + ], + ), + ); + } + + Widget _buildWheelPicker({ + required FixedExtentScrollController controller, + required List items, + required Function(int) onChanged, + required double width, + required double height, + required int selectedIndex, + bool isNumber = false, + TextEditingController? inputController, + Function(String)? onInputChanged, + bool isEditing = false, + FocusNode? focusNode, + VoidCallback? onTap, + VoidCallback? onEditingComplete, + }) { + return Container( + width: width, + height: height, + child: Stack( + children: [ + CupertinoPicker( + scrollController: controller, + itemExtent: 50.h, + onSelectedItemChanged: onChanged, + selectionOverlay: null, + diameterRatio: 99, + squeeze: 1.1, + children: items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isSelected = index == selectedIndex; + + return GestureDetector( + onTap: () { + if (isSelected) { + if (isNumber) { + onTap?.call(); + } + } else { + controller.animateToItem( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + behavior: HitTestBehavior.translucent, + child: Center( + child: Text( + item, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 20.sp, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ), + ); + }).toList(), + ), + if (isEditing && inputController != null && focusNode != null) + Center( + child: Container( + width: width, + height: 50.h, + alignment: Alignment.center, + decoration: const BoxDecoration(color: Colors.white), + child: TextField( + controller: inputController, + focusNode: focusNode, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + maxLength: 2, + autofocus: true, + onEditingComplete: onEditingComplete, + onTapOutside: (_) => onEditingComplete?.call(), + decoration: const InputDecoration( + counterText: "", + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 20.sp, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + onChanged: onInputChanged, + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/widgets/common/pet_selection_header.dart b/app/lib/widgets/common/pet_selection_header.dart new file mode 100644 index 0000000..0bbee12 --- /dev/null +++ b/app/lib/widgets/common/pet_selection_header.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../../models/pet_model.dart'; +import '../../theme/app_colors.dart'; + +class PetSelectionHeader extends StatelessWidget { + final List pets; + final Pet? selectedPet; + final ValueChanged onPetSelected; + final VoidCallback onAddPetPressed; + + const PetSelectionHeader({ + super.key, + required this.pets, + required this.selectedPet, + required this.onPetSelected, + required this.onAddPetPressed, + }); + + @override + Widget build(BuildContext context) { + // If no pet selected (and list is empty), show Add Pet UI or minimal hint + if (pets.isEmpty) { + // Returning null or minimal UI might be better, but user asked for "Consistent Header" + // The HomeScreen shows a "Register Pet + " button if empty. + // Let's implement that logic here for consistency if pets are empty. + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8.r), + onTap: onAddPetPressed, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 4.h, horizontal: 12.w), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/img/profile.png', + width: 40.w, + height: 40.h, + ), + SizedBox(width: 6.w), + Text( + '반려동물 등록 +', + style: TextStyle( + fontFamily: 'SCDream', + fontWeight: FontWeight.w500, + fontSize: 15.sp, + letterSpacing: 0.45.sp, + color: const Color(0xFF1f1f1f), + ), + ), + ], + ), + ), + ), + ); + } + + // Default display pet + final displayPet = selectedPet ?? pets.first; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8.r), + onTap: () { + final RenderBox button = context.findRenderObject() as RenderBox; + final RenderBox overlay = + Navigator.of(context).overlay!.context.findRenderObject() + as RenderBox; + final RelativeRect position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(Offset(0, 50.h), ancestor: overlay), + button.localToGlobal( + button.size.bottomRight(Offset.zero), + ancestor: overlay, + ), + ), + Offset.zero & overlay.size, + ); + + showMenu( + context: context, + position: position, + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + color: Colors.white, + surfaceTintColor: Colors.white, + items: [ + ...pets.map( + (pet) => PopupMenuItem( + value: pet, + child: Row( + children: [ + Container( + width: 32.w, + height: 32.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey[200], + ), + clipBehavior: Clip.hardEdge, + child: pet.profileImageUrl != null + ? Image.network( + pet.profileImageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Center( + child: SvgPicture.asset( + 'assets/icons/profile_icon.svg', + width: 16.w, + colorFilter: ColorFilter.mode( + Colors.grey[400]!, + BlendMode.srcIn, + ), + ), + ); + }, + ) + : Center( + child: SvgPicture.asset( + 'assets/icons/profile_icon.svg', + width: 16.w, + colorFilter: ColorFilter.mode( + Colors.grey[400]!, + BlendMode.srcIn, + ), + ), + ), + ), + SizedBox(width: 10.w), + Text( + pet.name, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 14.sp, + fontWeight: pet.id == displayPet.id + ? FontWeight.bold + : FontWeight.normal, + color: pet.id == displayPet.id + ? AppColors.highlight + : Colors.black, + ), + ), + if (pet.id == displayPet.id) ...[ + const Spacer(), + const Icon( + Icons.check, + color: AppColors.highlight, + size: 16, + ), + ], + ], + ), + ), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'add_pet', + child: Row( + children: [ + Container( + padding: EdgeInsets.all(4.w), + decoration: BoxDecoration( + color: Colors.grey[100], + shape: BoxShape.circle, + ), + child: Icon(Icons.add, size: 16.w, color: Colors.black54), + ), + SizedBox(width: 10.w), + Text( + '반려동물 추가하기', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 14.sp, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ).then((value) { + if (value != null) { + if (value is Pet) { + onPetSelected(value); + } else if (value == 'add_pet') { + onAddPetPressed(); + } + } + }); + }, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 4.h, horizontal: 12.w), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40.w, + height: 40.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey[200], + ), + clipBehavior: Clip.hardEdge, + child: displayPet.profileImageUrl != null + ? Image.network( + displayPet.profileImageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Center( + child: SvgPicture.asset( + 'assets/icons/profile_icon.svg', + width: 20.w, + colorFilter: ColorFilter.mode( + Colors.grey[400]!, + BlendMode.srcIn, + ), + ), + ); + }, + ) + : Center( + child: SvgPicture.asset( + 'assets/icons/profile_icon.svg', + width: 20.w, + colorFilter: ColorFilter.mode( + Colors.grey[400]!, + BlendMode.srcIn, + ), + ), + ), + ), + 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), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/daily_care/schedule_form_sheet.dart b/app/lib/widgets/daily_care/schedule_form_sheet.dart new file mode 100644 index 0000000..e986a08 --- /dev/null +++ b/app/lib/widgets/daily_care/schedule_form_sheet.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:provider/provider.dart'; +import '../../models/schedule_model.dart'; +import '../../providers/pet_provider.dart'; +import '../../services/firestore_service.dart'; +import '../../theme/app_colors.dart'; + +class ScheduleFormSheet extends StatefulWidget { + final DateTime selectedDate; + + const ScheduleFormSheet({super.key, required this.selectedDate}); + + @override + State createState() => _ScheduleFormSheetState(); +} + +class _ScheduleFormSheetState extends State { + final TextEditingController _titleController = TextEditingController(); + final TextEditingController _noteController = TextEditingController(); + String _selectedType = 'general'; // 'general' or 'important' + bool _isSubstituting = false; // "저장 중" 상태 표시 + + @override + void dispose() { + _titleController.dispose(); + _noteController.dispose(); + super.dispose(); + } + + Future _saveSchedule() async { + final title = _titleController.text.trim(); + if (title.isEmpty) return; + + final petProvider = context.read(); + final selectedPet = petProvider.selectedPet; + + if (selectedPet == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('반려동물을 먼저 선택해주세요.'))); + return; + } + + setState(() { + _isSubstituting = true; + }); + + try { + final firestoreService = FirestoreService(); + final newSchedule = Schedule( + id: firestoreService.generateScheduleId(), + petId: selectedPet.id, + date: widget.selectedDate, + type: _selectedType, + isCompleted: false, + title: title, + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + createdAt: DateTime.now(), + ); + + await firestoreService.addSchedule(newSchedule); + if (mounted) { + Navigator.pop(context, true); // 성공 시 true 반환 + } + } catch (e) { + debugPrint('Error saving schedule: $e'); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('일정 저장 실패: $e'))); + } + } finally { + if (mounted) { + setState(() { + _isSubstituting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + left: 20.w, + right: 20.w, + top: 20.h, + bottom: MediaQuery.of(context).viewInsets.bottom + 20.h, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24.r)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '일정 추가', + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 18.sp, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 10.h), + Divider(color: const Color(0xFFEEEEEE), thickness: 1.h), + SizedBox(height: 10.h), + SizedBox(height: 20.h), + + // 제목 입력 + TextField( + controller: _titleController, + decoration: InputDecoration( + labelText: '일정 제목', + floatingLabelStyle: const TextStyle(color: AppColors.highlight), + hintText: '예: 산책하기, 병원 방문', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide(color: AppColors.highlight), + ), + filled: true, + fillColor: Colors.grey[50], + ), + ), + SizedBox(height: 16.h), + + // 중요도 선택 + Row( + children: [ + Expanded( + child: _buildTypeButton( + '일반', + 'general', + AppColors.subHighlight, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: _buildTypeButton('중요', 'important', AppColors.highlight), + ), + ], + ), + SizedBox(height: 16.h), + + // 메모 입력 + TextField( + controller: _noteController, + maxLines: 3, + decoration: InputDecoration( + labelText: '메모 (선택)', + floatingLabelStyle: const TextStyle(color: AppColors.highlight), + hintText: '추가적인 내용을 입력하세요', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide(color: AppColors.highlight), + ), + filled: true, + fillColor: Colors.grey[50], + ), + ), + SizedBox(height: 24.h), + + // 저장 버튼 + ElevatedButton( + onPressed: _isSubstituting ? null : _saveSchedule, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.highlight, + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + elevation: 0, + ), + child: _isSubstituting + ? 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, + ), + ), + ), + ], + ), + ); + } + + Widget _buildTypeButton(String label, String value, Color color) { + final isSelected = _selectedType == value; + return InkWell( + onTap: () { + setState(() { + _selectedType = value; + }); + }, + borderRadius: BorderRadius.circular(12.r), + child: Container( + padding: EdgeInsets.symmetric(vertical: 12.h), + decoration: BoxDecoration( + color: isSelected ? color.withOpacity(0.1) : Colors.transparent, + border: Border.all( + color: isSelected ? color : Colors.grey[300]!, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12.r), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 12.w, + height: 12.w, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + SizedBox(width: 8.w), + Text( + label, + style: TextStyle( + fontFamily: 'SCDream', + fontSize: 14.sp, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? color : Colors.grey[600], + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/home/pet_profile_card.dart b/app/lib/widgets/home/pet_profile_card.dart index 72ba61b..9016ece 100644 --- a/app/lib/widgets/home/pet_profile_card.dart +++ b/app/lib/widgets/home/pet_profile_card.dart @@ -144,21 +144,33 @@ class PetProfileCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // 왼쪽: 프로필 이미지 // 왼쪽: 프로필 이미지 Container( width: 120.w, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16.r), color: Colors.grey[200], - image: pet.profileImageUrl != null - ? DecorationImage( - image: NetworkImage(pet.profileImageUrl!), - fit: BoxFit.cover, - ) - : null, ), - child: pet.profileImageUrl == null - ? Center( + clipBehavior: Clip.hardEdge, + child: pet.profileImageUrl != null + ? Image.network( + pet.profileImageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Center( + child: SvgPicture.asset( + 'assets/icons/profile_icon.svg', + width: 40.w, + colorFilter: ColorFilter.mode( + Colors.grey[400]!, + BlendMode.srcIn, + ), + ), + ); + }, + ) + : Center( child: SvgPicture.asset( 'assets/icons/profile_icon.svg', width: 40.w, @@ -167,8 +179,7 @@ class PetProfileCard extends StatelessWidget { BlendMode.srcIn, ), ), - ) - : null, + ), ), SizedBox(width: 10.w), // 오른쪽: 정보 영역 @@ -196,12 +207,12 @@ class PetProfileCard extends StatelessWidget { ), if (pet.gender == '남아' || pet.gender == '여아') ...[ SizedBox(width: 6.w), - Icon( - pet.gender == '남아' ? Icons.male : Icons.female, - color: pet.gender == '남아' - ? Colors.blue - : Colors.pinkAccent, - size: 20.sp, + SvgPicture.asset( + pet.gender == '남아' + ? 'assets/icons/male_icon.svg' + : 'assets/icons/female_icon.svg', + width: 20.w, + height: 20.w, ), ], ], diff --git a/app/pubspec.lock b/app/pubspec.lock index 0a6df21..a8daea9 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -616,6 +616,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.17.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" objective_c: dependency: transitive description: @@ -712,6 +720,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" pub_semver: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index b6978f7..7e88e42 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: firebase_storage: ^12.0.0 uuid: ^4.0.0 table_calendar: ^3.0.9 + provider: ^6.1.1 dev_dependencies: flutter_test: diff --git a/backend/index.js b/backend/index.js index 6002ec9..1401b72 100644 --- a/backend/index.js +++ b/backend/index.js @@ -5,6 +5,7 @@ const { connectDB, sequelize } = require('./config/db'); const authRoutes = require('./routes/auth'); const commonRoutes = require('./routes/common'); const petRoutes = require('./routes/pets'); +const scheduleRoutes = require('./routes/schedules'); const seedData = require('./scripts/seedData'); const app = express(); @@ -22,6 +23,7 @@ app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use('/auth', authRoutes); app.use('/common', commonRoutes); app.use('/pets', petRoutes); +app.use('/schedules', scheduleRoutes); app.get('/', (req, res) => { res.send('Hello from Express Backend!'); diff --git a/backend/models/Schedule.js b/backend/models/Schedule.js new file mode 100644 index 0000000..690c8a3 --- /dev/null +++ b/backend/models/Schedule.js @@ -0,0 +1,59 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/db'); + +const Schedule = sequelize.define('Schedule', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + petId: { + type: DataTypes.INTEGER, + allowNull: false, + comment: 'Reference to Pet table', + }, + date: { + type: DataTypes.DATE, + allowNull: false, + comment: 'Schedule start date/time', + }, + type: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'general', // 'general', 'important' + }, + isCompleted: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + note: { + type: DataTypes.TEXT, + allowNull: true, + }, + repeatInterval: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'Repeat interval (e.g. 1, 2)', + }, + repeatUnit: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Repeat unit (day, week, month, year)', + }, + isAlarmOn: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + alarmTime: { + type: DataTypes.DATE, + allowNull: true, + }, +}, { + timestamps: true, // adds createdAt, updatedAt +}); + +module.exports = Schedule; diff --git a/backend/models/index.js b/backend/models/index.js index a600c2c..aa09606 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -6,6 +6,7 @@ const PetSpecies = require('./PetSpecies'); const PetBreed = require('./PetBreed'); const PetDisease = require('./PetDisease'); const Pet = require('./Pet'); +const Schedule = require('./Schedule'); // Associations @@ -20,6 +21,10 @@ PetBreed.belongsTo(PetSpecies, { foreignKey: 'speciesId' }); User.hasMany(Pet, { foreignKey: 'userId' }); Pet.belongsTo(User, { foreignKey: 'userId' }); +// Pet <-> Schedule +Pet.hasMany(Schedule, { foreignKey: 'petId' }); +Schedule.belongsTo(Pet, { foreignKey: 'petId' }); + module.exports = { sequelize, @@ -29,4 +34,5 @@ module.exports = { PetBreed, PetDisease, Pet, + Schedule, }; diff --git a/backend/routes/pets.js b/backend/routes/pets.js index 54d5b21..5094f1b 100644 --- a/backend/routes/pets.js +++ b/backend/routes/pets.js @@ -34,6 +34,16 @@ router.post('/', upload.single('profileImage'), async (req, res) => { const profileImagePath = req.file ? `/uploads/pets/${req.file.filename}` : null; + // Check pet count limit (Max 20) + const currentPetCount = await Pet.count({ where: { userId } }); + if (currentPetCount >= 20) { + // Clean up uploaded file if limit exceeded + if (req.file) { + fs.unlinkSync(path.join(__dirname, '../uploads/pets/', req.file.filename)); + } + return res.status(400).json({ message: '최대 20마리까지만 등록할 수 있습니다.' }); + } + const newPet = await Pet.create({ userId, name, diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js new file mode 100644 index 0000000..4c9d5d7 --- /dev/null +++ b/backend/routes/schedules.js @@ -0,0 +1,108 @@ +const express = require('express'); +const router = express.Router(); +const { Schedule } = require('../models'); +const { Op } = require('sequelize'); + +// GET /schedules +// Query: petId (required), year, month (optional, for monthly view) +router.get('/', async (req, res) => { + try { + const { petId, year, month } = req.query; + + if (!petId) { + return res.status(400).json({ message: 'Missing petId query parameter' }); + } + + const whereClause = { petId }; + + if (year && month) { + const startDate = new Date(year, month - 1, 1); + const endDate = new Date(year, month, 0, 23, 59, 59); // Last day of month + + whereClause.date = { + [Op.between]: [startDate, endDate] + }; + } + + const schedules = await Schedule.findAll({ + where: whereClause, + order: [['date', 'ASC']], + }); + + res.json(schedules); + } catch (error) { + console.error('Error fetching schedules:', error); + res.status(500).json({ message: 'Server Error', error: error.toString() }); + } +}); + +// POST /schedules +router.post('/', async (req, res) => { + try { + const { + petId, date, type, isCompleted, title, note, + repeatInterval, repeatUnit, isAlarmOn, alarmTime + } = req.body; + + const newSchedule = await Schedule.create({ + petId, + date: new Date(date), + type, + isCompleted: isCompleted || false, + title, + note, + repeatInterval, + repeatUnit, + isAlarmOn, + alarmTime: alarmTime ? new Date(alarmTime) : null, + }); + + res.status(201).json(newSchedule); + } catch (error) { + console.error('Error creating schedule:', error); + res.status(500).json({ message: 'Server Error', error: error.toString() }); + } +}); + +// PUT /schedules/:id +router.put('/:id', async (req, res) => { + try { + const { id } = req.params; + const updateData = req.body; + + const schedule = await Schedule.findByPk(id); + if (!schedule) { + return res.status(404).json({ message: 'Schedule not found' }); + } + + // Safety checks / parsing + if (updateData.date) updateData.date = new Date(updateData.date); + if (updateData.alarmTime) updateData.alarmTime = new Date(updateData.alarmTime); + + await schedule.update(updateData); + + res.json(schedule); + } catch (error) { + console.error('Error updating schedule:', error); + res.status(500).json({ message: 'Server Error', error: error.toString() }); + } +}); + +// DELETE /schedules/:id +router.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + const schedule = await Schedule.findByPk(id); + if (!schedule) { + return res.status(404).json({ message: 'Schedule not found' }); + } + + await schedule.destroy(); + res.status(204).send(); + } catch (error) { + console.error('Error deleting schedule:', error); + res.status(500).json({ message: 'Server Error', error: error.toString() }); + } +}); + +module.exports = router; diff --git a/graphic_images.jpg b/graphic_images.jpg new file mode 100644 index 0000000..cc03558 Binary files /dev/null and b/graphic_images.jpg differ