658 lines
20 KiB
Dart
658 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
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 '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});
|
|
|
|
@override
|
|
State<DailyCareScreen> createState() => _DailyCareScreenState();
|
|
}
|
|
|
|
class _DailyCareScreenState extends State<DailyCareScreen> {
|
|
DateTime _focusedDay = DateTime.now();
|
|
DateTime _selectedDay = DateTime.now();
|
|
bool _isStampMode = false;
|
|
|
|
final DraggableScrollableController _sheetController =
|
|
DraggableScrollableController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_sheetController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// 날짜 정규화 (시간 제거)
|
|
DateTime _normalizeDate(DateTime date) {
|
|
return DateTime(date.year, date.month, date.day);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final provider = context.read<PetProvider>();
|
|
if (provider.userId == null) {
|
|
provider.loadUserAndPets();
|
|
}
|
|
});
|
|
}
|
|
|
|
List<Schedule> _getSchedulesForDay(
|
|
DateTime day,
|
|
Map<DateTime, List<Schedule>> scheduleMap,
|
|
) {
|
|
return scheduleMap[_normalizeDate(day)] ?? [];
|
|
}
|
|
|
|
// Data Variables
|
|
List<Schedule> _schedules = [];
|
|
bool _isLoadingSchedules = false;
|
|
String? _lastPetId;
|
|
DateTime? _lastFocusedMonth;
|
|
final ApiService _apiService = ApiService();
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_fetchSchedulesIfNeeded();
|
|
}
|
|
|
|
Future<void> _fetchSchedulesIfNeeded() async {
|
|
final petProvider = context.read<PetProvider>();
|
|
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<void> _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<Schedule> 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<PetProvider>();
|
|
if (petProvider.selectedPet != null) {
|
|
_fetchSchedules(petProvider.selectedPet!.id, focusedDay);
|
|
_lastFocusedMonth = focusedDay; // Update cache key
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final petProvider = context.watch<PetProvider>();
|
|
final selectedPet = petProvider.selectedPet;
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.white,
|
|
body: SafeArea(
|
|
child: Stack(
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildHeader(petProvider),
|
|
SizedBox(height: 10.h),
|
|
_buildCustomCalendarHeader(),
|
|
SizedBox(height: 30.h),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
|
child: selectedPet == null
|
|
? const Center(child: Text("반려동물을 선택해주세요"))
|
|
: Builder(
|
|
builder: (context) {
|
|
if (_isLoadingSchedules && _schedules.isEmpty) {
|
|
return const Center(
|
|
child: CircularProgressIndicator(),
|
|
);
|
|
}
|
|
|
|
// 데이터 처리: List<Schedule> -> Map<DateTime, List<Schedule>>
|
|
Map<DateTime, List<Schedule>> scheduleMap = {};
|
|
for (var schedule in _schedules) {
|
|
final date = _normalizeDate(schedule.date);
|
|
if (scheduleMap[date] == null) {
|
|
scheduleMap[date] = [];
|
|
}
|
|
scheduleMap[date]!.add(schedule);
|
|
}
|
|
|
|
return _buildCalendar(scheduleMap);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
_buildBottomSheet(),
|
|
],
|
|
),
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
heroTag: 'daily_care_fab',
|
|
onPressed: () {
|
|
if (petProvider.selectedPet == null) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('반려동물을 먼저 선택해주세요.')));
|
|
return;
|
|
}
|
|
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) =>
|
|
ScheduleFormScreen(selectedDate: _selectedDay),
|
|
),
|
|
);
|
|
},
|
|
backgroundColor: AppColors.highlight,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12.r),
|
|
),
|
|
child: SvgPicture.asset(
|
|
'assets/icons/add_icon.svg',
|
|
width: 24.w,
|
|
height: 24.w,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader(PetProvider petProvider) {
|
|
return Padding(
|
|
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();
|
|
}
|
|
|
|
return PetSelectionHeader(
|
|
pets: pets,
|
|
selectedPet: selectedPet,
|
|
onPetSelected: (value) {
|
|
context.read<PetProvider>().selectPet(value);
|
|
},
|
|
onAddPetPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const PetFormScreen()),
|
|
).then((value) {
|
|
if (value == true) {
|
|
context.read<PetProvider>().refreshPets();
|
|
}
|
|
});
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCustomCalendarHeader() {
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
InkWell(
|
|
onTap: () {
|
|
_onMonthChanged(
|
|
DateTime(_focusedDay.year, _focusedDay.month - 1),
|
|
);
|
|
},
|
|
child: SvgPicture.asset(
|
|
'assets/icons/left_icon.svg',
|
|
width: 12.w,
|
|
height: 12.w,
|
|
),
|
|
),
|
|
SizedBox(width: 30.w),
|
|
Text(
|
|
DateFormat('yyyy년 M월', 'ko_KR').format(_focusedDay),
|
|
style: TextStyle(
|
|
fontFamily: 'SCDream',
|
|
fontSize: 18.sp,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
SizedBox(width: 30.w),
|
|
InkWell(
|
|
onTap: () {
|
|
_onMonthChanged(
|
|
DateTime(_focusedDay.year, _focusedDay.month + 1),
|
|
);
|
|
},
|
|
child: SvgPicture.asset(
|
|
'assets/icons/right_icon.svg',
|
|
width: 12.w,
|
|
height: 12.w,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Positioned(
|
|
right: 0,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCalendar(Map<DateTime, List<Schedule>> scheduleMap) {
|
|
return TableCalendar<Schedule>(
|
|
shouldFillViewport: false,
|
|
locale: 'ko_KR',
|
|
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,
|
|
onDaySelected: (selectedDay, 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) {
|
|
if (focusedDay.month != _focusedDay.month ||
|
|
focusedDay.year != _focusedDay.year) {
|
|
_onMonthChanged(focusedDay);
|
|
}
|
|
},
|
|
calendarStyle: CalendarStyle(
|
|
defaultTextStyle: TextStyle(
|
|
fontFamily: 'SCDream',
|
|
fontWeight: FontWeight.w500,
|
|
fontSize: 15.sp,
|
|
color: const Color(0xFF1f1f1f),
|
|
),
|
|
weekendTextStyle: TextStyle(
|
|
fontFamily: 'SCDream',
|
|
fontWeight: FontWeight.w500,
|
|
fontSize: 15.sp,
|
|
color: const Color(0xFF1f1f1f),
|
|
),
|
|
todayDecoration: const BoxDecoration(
|
|
color: Colors.transparent,
|
|
shape: BoxShape.circle,
|
|
),
|
|
todayTextStyle: const TextStyle(
|
|
color: AppColors.highlight,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
selectedDecoration: const BoxDecoration(
|
|
color: Colors.transparent,
|
|
shape: BoxShape.circle,
|
|
),
|
|
selectedTextStyle: const TextStyle(
|
|
color: Colors.black,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
outsideDaysVisible: true,
|
|
),
|
|
eventLoader: (day) => _getSchedulesForDay(day, scheduleMap),
|
|
calendarBuilders: CalendarBuilders(
|
|
markerBuilder: (context, day, events) {
|
|
if (events.isEmpty) return null;
|
|
|
|
bool isOutside = day.month != _focusedDay.month;
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Positioned(
|
|
top: 32.h,
|
|
left: 0,
|
|
right: 0,
|
|
child: Opacity(
|
|
opacity: opacity,
|
|
child: Center(
|
|
child: Container(
|
|
width: 40.w,
|
|
child: Wrap(
|
|
alignment: WrapAlignment.start,
|
|
spacing: 2.w,
|
|
runSpacing: 2.h,
|
|
children: events.take(9).map((schedule) {
|
|
String iconPath =
|
|
'assets/icons/general_schedule_icon.svg';
|
|
|
|
// 상태/타입에 따른 아이콘 결정
|
|
if (schedule.isCompleted) {
|
|
iconPath = 'assets/icons/flower_icon.svg';
|
|
} else {
|
|
if (schedule.type == 'important') {
|
|
iconPath = 'assets/icons/important_schedule_icon.svg';
|
|
} else {
|
|
// 일반 일정 미완료
|
|
iconPath = 'assets/icons/incomplete_icon.svg';
|
|
}
|
|
}
|
|
|
|
return SvgPicture.asset(
|
|
iconPath,
|
|
width: 12.w,
|
|
height: 12.w,
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
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);
|
|
|
|
return Container(
|
|
padding: EdgeInsets.only(bottom: 10.h),
|
|
alignment: Alignment.bottomCenter,
|
|
child: Text(
|
|
text,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontFamily: 'SCDream',
|
|
fontSize: 12.sp,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
outsideBuilder: (context, day, focusedDay) {
|
|
Color color = const Color(0xFF1F1F1F).withOpacity(0.5);
|
|
if (day.weekday == DateTime.sunday) {
|
|
color = const Color(0xFFFF3F3F).withOpacity(0.5);
|
|
}
|
|
return _buildDayCell(day, color);
|
|
},
|
|
defaultBuilder: (context, day, focusedDay) {
|
|
Color color = const Color(0xFF1F1F1F);
|
|
if (day.weekday == DateTime.sunday) {
|
|
color = const Color(0xFFFF3F3F);
|
|
}
|
|
return _buildDayCell(day, color);
|
|
},
|
|
prioritizedBuilder: (context, day, focusedDay) {
|
|
final isToday = isSameDay(day, DateTime.now());
|
|
final isSelected = isSameDay(day, _selectedDay);
|
|
|
|
if (!isToday && !isSelected) return null;
|
|
|
|
final isSunday = day.weekday == DateTime.sunday;
|
|
Color textColor = const Color(0xFF1F1F1F);
|
|
if (isToday) {
|
|
textColor = const Color(0xFFFF9500);
|
|
} else if (isSunday) {
|
|
textColor = const Color(0xFFFF3F3F);
|
|
}
|
|
|
|
BoxDecoration? innerDecoration;
|
|
if (isSelected) {
|
|
innerDecoration = BoxDecoration(
|
|
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),
|
|
child: Text(
|
|
day.day.toString().padLeft(2, '0'),
|
|
style: TextStyle(
|
|
fontFamily: 'SCDream',
|
|
fontSize: 15.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: textColor,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
initialChildSize: 0.08,
|
|
minChildSize: 0.08,
|
|
maxChildSize: 0.8,
|
|
builder: (context, scrollController) {
|
|
return Container(
|
|
margin: EdgeInsets.symmetric(horizontal: 20.w),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24.r)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 5,
|
|
offset: const Offset(0, -3),
|
|
),
|
|
],
|
|
),
|
|
child: SingleChildScrollView(
|
|
controller: scrollController,
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
|
child: Column(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () {
|
|
if (_sheetController.size > 0.4) {
|
|
_sheetController.animateTo(
|
|
0.08,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
} else {
|
|
_sheetController.animateTo(
|
|
0.8,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
}
|
|
},
|
|
child: Container(
|
|
color: Colors.transparent,
|
|
width: double.infinity,
|
|
child: Column(
|
|
children: [
|
|
Icon(
|
|
Icons.keyboard_arrow_up_rounded,
|
|
color: Colors.grey[400],
|
|
size: 24.w,
|
|
),
|
|
SizedBox(height: 20.h),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
'일정 리스트 준비 중...', // TODO: Implement list
|
|
style: TextStyle(
|
|
fontFamily: 'SCDream',
|
|
fontSize: 14.sp,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 50.h),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|