diff --git a/.gitignore b/.gitignore index 79c113f..044af3b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# 민감한 정보가 포함된 파일 +lib/config/secrets.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d4deff8..b67e975 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "com.example.heafit" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f2c3c28..22765fd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/images/category/Bibimbap .png b/assets/images/category/Bibimbap .png new file mode 100644 index 0000000..36654da Binary files /dev/null and b/assets/images/category/Bibimbap .png differ diff --git a/assets/images/category/Book.png b/assets/images/category/Book.png new file mode 100644 index 0000000..38d37e2 Binary files /dev/null and b/assets/images/category/Book.png differ diff --git a/assets/images/category/Bus.png b/assets/images/category/Bus.png new file mode 100644 index 0000000..90e5179 Binary files /dev/null and b/assets/images/category/Bus.png differ diff --git a/assets/images/category/Clothes .png b/assets/images/category/Clothes .png new file mode 100644 index 0000000..f117fb0 Binary files /dev/null and b/assets/images/category/Clothes .png differ diff --git a/assets/images/category/Coffee.png b/assets/images/category/Coffee.png new file mode 100644 index 0000000..9274c33 Binary files /dev/null and b/assets/images/category/Coffee.png differ diff --git a/assets/images/category/Exhibition .png b/assets/images/category/Exhibition .png new file mode 100644 index 0000000..f8fa75b Binary files /dev/null and b/assets/images/category/Exhibition .png differ diff --git a/assets/images/category/Jjajangmyeon .png b/assets/images/category/Jjajangmyeon .png new file mode 100644 index 0000000..bbbc277 Binary files /dev/null and b/assets/images/category/Jjajangmyeon .png differ diff --git a/assets/images/category/Laptop.png b/assets/images/category/Laptop.png new file mode 100644 index 0000000..0303113 Binary files /dev/null and b/assets/images/category/Laptop.png differ diff --git a/assets/images/category/Makeup.png b/assets/images/category/Makeup.png new file mode 100644 index 0000000..9ebeef8 Binary files /dev/null and b/assets/images/category/Makeup.png differ diff --git a/assets/images/category/Movie.png b/assets/images/category/Movie.png new file mode 100644 index 0000000..764a803 Binary files /dev/null and b/assets/images/category/Movie.png differ diff --git a/assets/images/category/Performance .png b/assets/images/category/Performance .png new file mode 100644 index 0000000..0c628c5 Binary files /dev/null and b/assets/images/category/Performance .png differ diff --git a/assets/images/category/Spaghetti.png b/assets/images/category/Spaghetti.png new file mode 100644 index 0000000..c1b9c4a Binary files /dev/null and b/assets/images/category/Spaghetti.png differ diff --git a/assets/images/category/Sushi.png b/assets/images/category/Sushi.png new file mode 100644 index 0000000..5edc73a Binary files /dev/null and b/assets/images/category/Sushi.png differ diff --git a/assets/images/category/Tteokbokki.png b/assets/images/category/Tteokbokki.png new file mode 100644 index 0000000..de13de5 Binary files /dev/null and b/assets/images/category/Tteokbokki.png differ diff --git a/assets/images/category/subway.png b/assets/images/category/subway.png new file mode 100644 index 0000000..12675f9 Binary files /dev/null and b/assets/images/category/subway.png differ diff --git a/assets/images/change-cal.png b/assets/images/change-cal.png new file mode 100644 index 0000000..8857d11 Binary files /dev/null and b/assets/images/change-cal.png differ diff --git a/assets/images/like-category.png b/assets/images/like-category.png new file mode 100644 index 0000000..ed39a77 Binary files /dev/null and b/assets/images/like-category.png differ diff --git a/assets/images/use-al.png b/assets/images/use-al.png new file mode 100644 index 0000000..9eb9329 Binary files /dev/null and b/assets/images/use-al.png differ diff --git a/assets/logo/heafit-logo2.png b/assets/logo/heafit-logo2.png new file mode 100644 index 0000000..bde7414 Binary files /dev/null and b/assets/logo/heafit-logo2.png differ diff --git a/lib/config/secrets.dart.example b/lib/config/secrets.dart.example new file mode 100644 index 0000000..e219077 --- /dev/null +++ b/lib/config/secrets.dart.example @@ -0,0 +1,7 @@ +/// 비밀 API 키 및 클라이언트 ID를 관리하는 클래스 +/// 이 파일은 예시 템플릿입니다. 실제 사용을 위해 secrets.dart로 복사한 후 실제 값을 입력하세요. +class Secrets { + /// 구글 OAuth 클라이언트 ID + /// GCP 콘솔에서 발급받은 Android 클라이언트 ID를 여기에 입력 + static const String googleClientId = 'YOUR_OAUTH_CLIENT_ID_HERE.apps.googleusercontent.com'; +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index c101432..5b8f611 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:heafit/screens/splash_screen.dart'; import 'package:heafit/constants/theme.dart'; +import 'package:heafit/services/google_auth_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -10,6 +11,10 @@ void main() async { // Hive 초기화 await Hive.initFlutter(); + // Google Auth Service 초기화 + final googleAuthService = GoogleAuthService(); + await googleAuthService.init(); + runApp(const MyApp()); } @@ -23,7 +28,7 @@ class MyApp extends StatelessWidget { debugShowCheckedModeBanner: false, theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, - themeMode: ThemeMode.system, // 시스템 설정에 따라 테마 적용 + themeMode: ThemeMode.light, // 항상 라이트 테마 적용 home: const SplashScreen(), ); } diff --git a/lib/screens/calendar_screen.dart b/lib/screens/calendar_screen.dart index 78f1da8..9029352 100644 --- a/lib/screens/calendar_screen.dart +++ b/lib/screens/calendar_screen.dart @@ -1,90 +1,668 @@ import 'package:flutter/material.dart'; import 'package:heafit/constants/theme.dart'; import 'package:table_calendar/table_calendar.dart'; +import 'package:heafit/services/google_auth_service.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:googleapis/calendar/v3.dart' as calendar; class CalendarScreen extends StatefulWidget { - const CalendarScreen({Key? key}) : super(key: key); + final List? highlightDates; + + const CalendarScreen({Key? key, this.highlightDates}) : super(key: key); @override State createState() => _CalendarScreenState(); } class _CalendarScreenState extends State { + final GoogleAuthService _googleAuthService = GoogleAuthService(); + bool _isLoadingAuth = true; + bool _isCalendarConnected = false; + bool _isLoadingEvents = false; + CalendarFormat _calendarFormat = CalendarFormat.month; DateTime _focusedDay = DateTime.now(); DateTime? _selectedDay; - // 임시 일정 데이터 - final Map>> _events = {}; + // 이벤트 데이터 + Map> _events = {}; @override void initState() { super.initState(); _selectedDay = _focusedDay; - _loadEvents(); + _checkCalendarConnection(); } - // 임시 이벤트 데이터 로드 - void _loadEvents() { - final now = DateTime.now(); - - // 오늘 일정 - final today = DateTime(now.year, now.month, now.day); - _events[today] = [ - { - 'title': '스타벅스 방문', - 'time': '15:00', - 'benefit': '신한카드 50% 할인', - 'isUsed': true, - }, - { - 'title': 'CGV 영화 관람', - 'time': '19:30', - 'benefit': '토스 결제 시 1+1', - 'isUsed': false, - }, - ]; - - // 내일 일정 - final tomorrow = DateTime(now.year, now.month, now.day + 1); - _events[tomorrow] = [ - { - 'title': '배달의민족 주문', - 'time': '12:00', - 'benefit': '네이버페이 3,000원 할인', - 'isUsed': null, + // 구글 캘린더 연결 상태 확인 + Future _checkCalendarConnection() async { + await _googleAuthService.init(); + setState(() { + _isCalendarConnected = _googleAuthService.isCalendarConnected; + _isLoadingAuth = false; + }); + + if (_isCalendarConnected) { + // 먼저 이벤트를 로드 + await _loadEvents(); + + // 동기화 설정이 있는 경우에만 자동 동기화 수행 + final syncSourceCalendarIds = _googleAuthService.syncSourceCalendarIds; + if (syncSourceCalendarIds.isNotEmpty) { + // 자동 동기화 로직 실행 + await _syncSelectedCalendars(); + } else { + setState(() { + _isLoadingEvents = false; + }); + } + } + } + + // 동기화 설정에 따라 캘린더 동기화 수행 + Future _syncSelectedCalendars() async { + if (!_isCalendarConnected) return; + + // 저장된 동기화 설정 가져오기 + final syncSourceCalendarIds = _googleAuthService.syncSourceCalendarIds; + + if (syncSourceCalendarIds.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('캘린더 동기화가 해제되어 있습니다. 프로필에서 동기화 설정을 확인해주세요.'), + backgroundColor: Colors.blue, + duration: Duration(seconds: 3), + ), + ); + setState(() { + _isLoadingEvents = false; + }); + return; + } + + setState(() { + _isLoadingEvents = true; + }); + + try { + // 동기화 기간 설정: 현재 월 및 이전 월의 일정만 동기화 + final now = DateTime.now(); + // 현재 년도의 1월 1일 (1월이면 전년도의 12월 1일) + final startMonth = now.month > 1 ? 1 : 12; + final startYear = now.month > 1 ? now.year : now.year - 1; + final startTime = DateTime(startYear, startMonth, 1); + + // 현재 월의 마지막 날 + final endTime = DateTime(now.year, now.month + 1, 0); + + debugPrint( + '캘린더 동기화 시작 - 범위: ${startTime.toString()} ~ ${endTime.toString()}', + ); + + final syncedCount = await _googleAuthService.syncCalendarToHeafit( + sourceCalendarIds: syncSourceCalendarIds, + startTime: startTime, + endTime: endTime, + ); + + if (syncedCount > 0) { + // 동기화 후 이벤트 다시 로드 + await _loadEvents(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '$syncedCount개의 일정이 동기화되었습니다.\n현재 월과 이전 월의 일정만 동기화됩니다.', + ), + backgroundColor: AppTheme.primaryColor, + duration: const Duration(seconds: 3), + ), + ); + } else if (syncedCount == 0) { + // 동기화할 내용이 없는 경우 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('새로운 동기화 대상이 없습니다. 모든 일정이 최신 상태입니다.'), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + debugPrint('자동 동기화 오류: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('동기화 중 오류가 발생했습니다: $e'), + backgroundColor: Colors.red, + ), + ); + } finally { + setState(() { + _isLoadingEvents = false; + }); + } + } + + // 구글 계정으로 로그인하고 캘린더 연결 + Future _connectGoogleCalendar() async { + setState(() { + _isLoadingAuth = true; + }); + + final success = await _googleAuthService.signIn(); + + setState(() { + _isCalendarConnected = success; + _isLoadingAuth = false; + }); + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('구글 캘린더가 성공적으로 연결되었습니다!'), + backgroundColor: AppTheme.primaryColor, + ), + ); + await _loadEvents(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('구글 캘린더 연결에 실패했습니다. 다시 시도해주세요.'), + backgroundColor: Colors.red, + ), + ); + } + } + + // 캘린더 설정 다이얼로그 표시 + void _showCalendarSettings() { + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: const Text('캘린더 설정'), + content: SizedBox( + width: double.maxFinite, + height: 300, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '표시할 캘린더', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + itemCount: _googleAuthService.userCalendars.length, + itemBuilder: (context, index) { + final calendar = + _googleAuthService.userCalendars[index]; + final calendarId = calendar.id ?? ''; + final isVisible = _googleAuthService + .isCalendarVisible(calendarId); + + return CheckboxListTile( + title: Text(calendar.summary ?? '이름 없음'), + subtitle: Text( + calendar.id == _googleAuthService.heafitCalendarId + ? 'Heafit 캘린더' + : calendar.id == 'primary' + ? '기본 캘린더' + : '', + ), + value: isVisible, + activeColor: AppTheme.primaryColor, + onChanged: (value) async { + if (value == true) { + await _googleAuthService.addVisibleCalendar( + calendarId, + ); + } else { + await _googleAuthService.removeVisibleCalendar( + calendarId, + ); + } + + setState(() {}); + + // 메인 상태 업데이트를 위한 이벤트 다시 로드 + if (mounted) { + await _loadEvents(); + this.setState(() {}); + } + }, + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('확인'), + ), + ], + ); + }, + ); }, - ]; - - // 일주일 후 일정 - final nextWeek = DateTime(now.year, now.month, now.day + 7); - _events[nextWeek] = [ - { - 'title': '올리브영 방문', - 'time': '14:00', - 'benefit': '현대카드 20% 할인', - 'isUsed': null, + ); + } + + // 일정 추가 다이얼로그 표시 + void _showAddEventDialog() { + final titleController = TextEditingController(); + final descriptionController = TextEditingController(); + final locationController = TextEditingController(); + + DateTime selectedDate = _selectedDay ?? DateTime.now(); + TimeOfDay startTime = TimeOfDay.now(); + TimeOfDay endTime = TimeOfDay( + hour: TimeOfDay.now().hour + 1, + minute: TimeOfDay.now().minute, + ); + + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: const Text('혜택 일정 추가'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: titleController, + decoration: const InputDecoration( + labelText: '제목', + hintText: '예: 스타벅스 방문', + ), + ), + const SizedBox(height: 12), + TextField( + controller: descriptionController, + decoration: const InputDecoration( + labelText: '혜택 설명', + hintText: '예: 신한카드 50% 할인', + ), + maxLines: 2, + ), + const SizedBox(height: 12), + TextField( + controller: locationController, + decoration: const InputDecoration( + labelText: '위치 (선택)', + hintText: '예: 강남역점', + ), + ), + const SizedBox(height: 20), + Row( + children: [ + const Text( + '날짜: ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextButton( + onPressed: () async { + final pickedDate = await showDatePicker( + context: context, + initialDate: selectedDate, + firstDate: DateTime.now(), + lastDate: DateTime.now().add( + const Duration(days: 365), + ), + ); + if (pickedDate != null) { + setState(() { + selectedDate = pickedDate; + }); + } + }, + child: Text( + '${selectedDate.year}년 ${selectedDate.month}월 ${selectedDate.day}일', + ), + ), + ], + ), + Row( + children: [ + const Text( + '시작 시간: ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextButton( + onPressed: () async { + final pickedTime = await showTimePicker( + context: context, + initialTime: startTime, + ); + if (pickedTime != null) { + setState(() { + startTime = pickedTime; + // 종료 시간이 시작 시간보다 이전이면 조정 + if (startTime.hour > endTime.hour || + (startTime.hour == endTime.hour && + startTime.minute >= endTime.minute)) { + endTime = TimeOfDay( + hour: startTime.hour + 1, + minute: startTime.minute, + ); + } + }); + } + }, + child: Text( + '${startTime.hour}:${startTime.minute.toString().padLeft(2, '0')}', + ), + ), + ], + ), + Row( + children: [ + const Text( + '종료 시간: ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextButton( + onPressed: () async { + final pickedTime = await showTimePicker( + context: context, + initialTime: endTime, + ); + if (pickedTime != null) { + setState(() { + endTime = pickedTime; + }); + } + }, + child: Text( + '${endTime.hour}:${endTime.minute.toString().padLeft(2, '0')}', + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소'), + ), + ElevatedButton( + onPressed: () async { + if (titleController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('제목을 입력해주세요.')), + ); + return; + } + + // 일정 추가 + final startDateTime = DateTime( + selectedDate.year, + selectedDate.month, + selectedDate.day, + startTime.hour, + startTime.minute, + ); + + final endDateTime = DateTime( + selectedDate.year, + selectedDate.month, + selectedDate.day, + endTime.hour, + endTime.minute, + ); + + final success = await _googleAuthService.addEventToCalendar( + title: titleController.text, + description: descriptionController.text, + startTime: startDateTime, + endTime: endDateTime, + location: + locationController.text.isEmpty + ? null + : locationController.text, + calendarId: _googleAuthService.heafitCalendarId, + ); + + Navigator.pop(context); + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('일정이 추가되었습니다.'), + backgroundColor: AppTheme.primaryColor, + ), + ); + + // 이벤트 목록 새로고침 + await _loadEvents(); + setState(() {}); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('일정 추가에 실패했습니다.'), + backgroundColor: Colors.red, + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + ), + child: const Text('추가'), + ), + ], + ); + }, + ); }, - ]; + ); } - List> _getEventsForDay(DateTime day) { + // 구글 캘린더에서 이벤트 로드 + Future _loadEvents() async { + if (!_isCalendarConnected) return; + + setState(() { + _isLoadingEvents = true; + }); + + try { + // 캘린더 기간 설정 (현재 달력에서 보여지는 범위에 맞춰 조정) + final firstDay = DateTime(_focusedDay.year, _focusedDay.month, 1); + final lastDay = DateTime(_focusedDay.year, _focusedDay.month + 1, 0); + + // 이벤트 불러오기 + final events = await _googleAuthService.getEvents( + startTime: firstDay, + endTime: lastDay, + ); + + debugPrint('로드된 이벤트 수: ${events.length}'); + + // 날짜별로 이벤트 정리 + final Map> groupedEvents = {}; + + for (final event in events) { + DateTime? eventDate; + + // 날짜 추출 (dateTime이 있으면 사용하고, date만 있으면 date 사용) + if (event.start?.dateTime != null) { + eventDate = DateTime( + event.start!.dateTime!.year, + event.start!.dateTime!.month, + event.start!.dateTime!.day, + ); + } else if (event.start?.date != null) { + eventDate = event.start!.date; + } + + // 날짜가 있는 이벤트만 추가 + if (eventDate != null) { + if (groupedEvents[eventDate] == null) { + groupedEvents[eventDate] = []; + } + + groupedEvents[eventDate]!.add(event); + } + } + + setState(() { + _events = groupedEvents; + _isLoadingEvents = false; + }); + } catch (e) { + debugPrint('이벤트 로드 오류: $e'); + setState(() { + _isLoadingEvents = false; + }); + } + } + + // 날짜에 해당하는 이벤트 목록 반환 + List _getEventsForDay(DateTime day) { final normalizedDay = DateTime(day.year, day.month, day.day); return _events[normalizedDay] ?? []; } @override Widget build(BuildContext context) { + // 로딩 중이면 로딩 화면 표시 + if (_isLoadingAuth) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppTheme.primaryColor), + ), + ), + ); + } + + // 캘린더 미연결 상태라면 연결 안내 화면 표시 + if (!_isCalendarConnected) { + return _buildCalendarConnectionScreen(); + } + + // 캘린더 연결 완료된 상태라면 일정 화면 표시 + return _buildCalendarScreen(); + } + + // 캘린더 연결 안내 화면 + Widget _buildCalendarConnectionScreen() { + return Scaffold( + appBar: AppBar(title: const Text('내 혜택 일정'), elevation: 0), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 캘린더 아이콘 + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: AppTheme.primaryLight.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.calendar_month, + size: 64, + color: AppTheme.primaryColor, + ), + ), + const SizedBox(height: 32), + + // 안내 텍스트 + const Text( + '구글 캘린더 연결하기', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppTheme.textColor, + ), + ), + const SizedBox(height: 16), + + const Text( + '구글 캘린더를 연결하면 혜택 정보를 일정에 손쉽게 추가하고 관리할 수 있어요. 캘린더에 저장된 일정을 기반으로 혜택도 추천받을 수 있습니다.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: AppTheme.secondaryTextColor, + height: 1.5, + ), + ), + const SizedBox(height: 40), + + // 구글 연결 버튼 + ElevatedButton.icon( + onPressed: _connectGoogleCalendar, + icon: const Icon(Icons.calendar_month), + label: const Text('구글 캘린더 연결하기'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ], + ), + ), + ), + ); + } + + // 캘린더 화면 (연결 완료 시) + Widget _buildCalendarScreen() { return Scaffold( appBar: AppBar( title: const Text('내 혜택 일정'), elevation: 0, actions: [ + // 새로고침 버튼 (동기화 중이면 로딩 인디케이터 표시) + _isLoadingEvents + ? const Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ), + ) + : IconButton( + icon: const Icon(Icons.sync), + onPressed: _syncSelectedCalendars, + tooltip: '설정된 캘린더 동기화하기', + ), IconButton( - icon: const Icon(Icons.filter_list), - onPressed: () { - // 캘린더 필터 옵션 표시 - }, + icon: const Icon(Icons.settings), + onPressed: _showCalendarSettings, ), ], ), @@ -112,8 +690,67 @@ class _CalendarScreenState extends State { }, onPageChanged: (focusedDay) { _focusedDay = focusedDay; + _loadEvents(); // 페이지 변경 시 해당 달의 이벤트 로드 + // 페이지 변경 시 동기화 수행 (선택적으로 활성화) + // _syncSelectedCalendars(); }, eventLoader: _getEventsForDay, + calendarBuilders: CalendarBuilders( + markerBuilder: (context, date, events) { + if (events.isEmpty) return const SizedBox.shrink(); + + // 최대 표시할 마커 개수 + final maxMarkers = 3; + + return Positioned( + bottom: 1, + child: Row( + mainAxisSize: MainAxisSize.min, + children: + events.take(maxMarkers).map((event) { + // 이벤트가 속한 캘린더 결정 + final calendarId = + (event as calendar.Event).organizer?.email ?? ''; + Color markerColor = AppTheme.primaryColor; + + // Heafit 캘린더 여부 확인 + if (calendarId == + _googleAuthService.heafitCalendarId) { + markerColor = AppTheme.primaryColor; + } else { + // 캘린더 목록에서 해당 캘린더 찾기 + final calendarEntry = _googleAuthService + .userCalendars + .firstWhere( + (cal) => cal.id == calendarId, + orElse: () => calendar.CalendarListEntry(), + ); + + if (calendarEntry.backgroundColor != null) { + try { + final colorCode = calendarEntry.backgroundColor! + .replaceFirst('#', '0xFF'); + markerColor = Color(int.parse(colorCode)); + } catch (e) { + debugPrint('색상 변환 오류: $e'); + } + } + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 1.0), + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: markerColor, + ), + ); + }).toList(), + ), + ); + }, + ), calendarStyle: CalendarStyle( markersMaxCount: 3, markerDecoration: const BoxDecoration( @@ -158,25 +795,33 @@ class _CalendarScreenState extends State { const SizedBox(height: 8), // 선택된 날짜의 일정 목록 - Expanded( - child: - _selectedDay != null - ? _buildEventsList(_getEventsForDay(_selectedDay!)) - : _buildEventsList(_getEventsForDay(DateTime.now())), - ), + _isLoadingEvents + ? const Expanded( + child: Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + AppTheme.primaryColor, + ), + ), + ), + ) + : Expanded( + child: + _selectedDay != null + ? _buildEventsList(_getEventsForDay(_selectedDay!)) + : _buildEventsList(_getEventsForDay(DateTime.now())), + ), ], ), floatingActionButton: FloatingActionButton( - onPressed: () { - // 혜택 일정 추가 - }, + onPressed: _showAddEventDialog, backgroundColor: AppTheme.primaryColor, child: const Icon(Icons.add), ), ); } - Widget _buildEventsList(List> events) { + Widget _buildEventsList(List events) { if (events.isEmpty) { return const Center( child: Text( @@ -191,22 +836,57 @@ class _CalendarScreenState extends State { itemCount: events.length, itemBuilder: (context, index) { final event = events[index]; - final Color statusColor = - event['isUsed'] == null - ? Colors.grey - : event['isUsed'] - ? Colors.green - : Colors.red; - final String statusText = - event['isUsed'] == null - ? '예정' - : event['isUsed'] - ? '사용 완료' - : '미사용'; + final String title = event.summary ?? '제목 없음'; + final String description = event.description ?? ''; + final DateTime? startTime = event.start?.dateTime; + final String location = event.location ?? ''; + + // 이벤트가 어떤 캘린더에 속하는지 확인 + final String calendarId = event.organizer?.email ?? ''; + + // 캘린더별 색상 설정 + Color calendarColor = AppTheme.primaryColor; + String calendarName = '기본'; + + // 캘린더 ID에 따라 적절한 색상 할당 + if (calendarId == _googleAuthService.heafitCalendarId) { + calendarName = 'Heafit'; + calendarColor = AppTheme.primaryColor; + } else { + // 캘린더 목록에서 해당 캘린더 찾기 + final calendarEntry = _googleAuthService.userCalendars.firstWhere( + (cal) => cal.id == calendarId, + orElse: () => calendar.CalendarListEntry(), + ); + + if (calendarEntry.backgroundColor != null) { + // 구글 캘린더 색상 코드(HEX)를 Flutter Color로 변환 + try { + final colorCode = calendarEntry.backgroundColor!.replaceFirst( + '#', + '0xFF', + ); + calendarColor = Color(int.parse(colorCode)); + } catch (e) { + debugPrint('색상 변환 오류: $e'); + } + } + + calendarName = calendarEntry.summary ?? '기타'; + } + + // 이벤트가 Heafit 캘린더에 있는지 확인 + final bool isHeafitEvent = + calendarId == _googleAuthService.heafitCalendarId || + description.contains('혜택'); return Card( margin: const EdgeInsets.only(bottom: 16), elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: calendarColor, width: 1.5), + ), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -216,7 +896,9 @@ class _CalendarScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - event['time'], + startTime != null + ? '${startTime.hour}:${startTime.minute.toString().padLeft(2, '0')}' + : '종일', style: const TextStyle(fontSize: 14, color: Colors.grey), ), Container( @@ -225,14 +907,14 @@ class _CalendarScreenState extends State { vertical: 4, ), decoration: BoxDecoration( - color: statusColor.withOpacity(0.1), + color: calendarColor.withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), child: Text( - statusText, + calendarName, style: TextStyle( fontSize: 12, - color: statusColor, + color: calendarColor, fontWeight: FontWeight.bold, ), ), @@ -241,59 +923,80 @@ class _CalendarScreenState extends State { ), const SizedBox(height: 8), Text( - event['title'], + title, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 8), - Row( - children: [ - const Icon( - Icons.card_giftcard, - size: 16, - color: AppTheme.primaryColor, - ), - const SizedBox(width: 4), - Text( - event['benefit'], - style: const TextStyle( - fontSize: 14, - color: AppTheme.primaryColor, - ), - ), - ], - ), - const SizedBox(height: 16), - if (event['isUsed'] == null) + if (description.isNotEmpty) ...[ + const SizedBox(height: 8), Row( children: [ + Icon(Icons.card_giftcard, size: 16, color: calendarColor), + const SizedBox(width: 4), Expanded( - child: OutlinedButton( - onPressed: () { - // 사용 안함으로 표시 - }, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.red, - ), - child: const Text('사용 안함'), + child: Text( + description, + style: const TextStyle(fontSize: 14), ), ), - const SizedBox(width: 8), + ], + ), + ], + if (location.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Icon( + Icons.location_on, + size: 16, + color: Colors.grey, + ), + const SizedBox(width: 4), Expanded( - child: ElevatedButton( - onPressed: () { - // 사용함으로 표시 - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, + child: Text( + location, + style: const TextStyle( + fontSize: 14, + color: Colors.grey, ), - child: const Text('사용함'), ), ), ], ), + ], + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () { + // 일정 공유 + }, + icon: const Icon(Icons.share, size: 16), + label: const Text('공유'), + style: TextButton.styleFrom( + foregroundColor: Colors.grey, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () { + // 알림 설정 + }, + icon: const Icon(Icons.notifications, size: 16), + label: const Text('알림'), + style: TextButton.styleFrom( + foregroundColor: Colors.grey, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), ], ), ), diff --git a/lib/screens/category_screen.dart b/lib/screens/category_screen.dart index b9dfc50..31a23f5 100644 --- a/lib/screens/category_screen.dart +++ b/lib/screens/category_screen.dart @@ -1,318 +1,890 @@ import 'package:flutter/material.dart'; import 'package:heafit/constants/theme.dart'; import 'package:heafit/widgets/section_title.dart'; +import 'package:heafit/services/google_auth_service.dart'; +import 'package:intl/intl.dart'; + +class BenefitDetail { + final String title; + final String company; + final String description; + final String period; + final String imageUrl; + final List tags; + final String paymentMethod; + final double discountRate; + bool isFavorite; + + BenefitDetail({ + required this.title, + required this.company, + required this.description, + required this.period, + required this.imageUrl, + required this.tags, + required this.paymentMethod, + required this.discountRate, + this.isFavorite = false, + }); +} class CategoryScreen extends StatefulWidget { - const CategoryScreen({Key? key}) : super(key: key); + final String? initialCategory; + final String? initialSubCategory; + final bool showFavoritesOnly; + // 알림 상태 변경 콜백 + final Function(bool)? onNotificationStateChanged; + + const CategoryScreen({ + super.key, + this.initialCategory, + this.initialSubCategory, + this.showFavoritesOnly = false, + this.onNotificationStateChanged, + }); @override State createState() => _CategoryScreenState(); } -class _CategoryScreenState extends State { +class _CategoryScreenState extends State + with SingleTickerProviderStateMixin { + // Google 인증 서비스 + final GoogleAuthService _googleAuthService = GoogleAuthService(); + final bool _isCalendarConnected = false; + // 선택된 메인 카테고리 인덱스 int _selectedCategoryIndex = 0; - // 메인 카테고리 목록 + // 선택된 서브 카테고리 + String? _selectedSubCategory; + + // 즐겨찾기 필터링 여부 + bool _showFavoritesOnly = false; + + // 혜택 상세 정보 표시 여부 + bool _showBenefitDetail = false; + + // 알림 화면 표시 여부 + bool _showNotifications = false; + + // 선택된 혜택 + BenefitDetail? _selectedBenefit; + + // 메인 카테고리 리스트 final List> _mainCategories = [ - {'name': '전체', 'icon': Icons.apps}, - {'name': '카페', 'icon': Icons.coffee}, - {'name': '음식점', 'icon': Icons.restaurant}, + {'name': '음식', 'icon': Icons.restaurant}, {'name': '쇼핑', 'icon': Icons.shopping_bag}, - {'name': '영화', 'icon': Icons.movie}, - {'name': '뷰티', 'icon': Icons.face}, - {'name': '여행', 'icon': Icons.flight}, + {'name': '교통', 'icon': Icons.directions_car}, + {'name': '엔터테인먼트', 'icon': Icons.movie}, ]; - // 서브 카테고리 (브랜드/체인점) 목록 - 카페 카테고리 예시 - final List> _cafeSubCategories = [ - {'name': '스타벅스', 'imageUrl': 'assets/images/starbucks.jpg'}, - {'name': '투썸플레이스', 'imageUrl': 'assets/images/twosome.jpg'}, - {'name': '이디야', 'imageUrl': 'assets/images/ediya.jpg'}, - {'name': '커피빈', 'imageUrl': 'assets/images/coffeebean.jpg'}, - {'name': '할리스', 'imageUrl': 'assets/images/hollys.jpg'}, - {'name': '폴바셋', 'imageUrl': 'assets/images/paulbassett.jpg'}, - ]; + // 서브 카테고리 리스트 (카테고리별) + final Map>> _subCategories = { + '음식': [ + {'name': '한식', 'imageUrl': 'assets/images/category/Bibimbap .png'}, + {'name': '중식', 'imageUrl': 'assets/images/category/Jjajangmyeon .png'}, + {'name': '카페/디저트', 'imageUrl': 'assets/images/category/Coffee.png'}, + ], + '쇼핑': [ + {'name': '패션/의류', 'imageUrl': 'assets/images/category/Clothes .png'}, + {'name': '뷰티/화장품', 'imageUrl': 'assets/images/category/Makeup.png'}, + {'name': '디지털/가전', 'imageUrl': 'assets/images/category/Laptop.png'}, + {'name': '도서/문구', 'imageUrl': 'assets/images/category/Book.png'}, + ], + '교통': [ + {'name': '대중교통', 'imageUrl': 'assets/images/category/Bus.png'}, + ], + '엔터테인먼트': [ + {'name': '영화', 'imageUrl': 'assets/images/category/Movie.png'}, + {'name': '공연', 'imageUrl': 'assets/images/category/Performance .png'}, + {'name': '전시', 'imageUrl': 'assets/images/category/Exhibition .png'}, + ], + }; - // 혜택 목록 - 스타벅스 예시 - final List> _benefits = [ + // 혜택 정보 리스트 (카테고리별) + final Map> _benefitsList = { + '한식': [ + BenefitDetail( + title: '본죽 5,000원 할인', + company: '본죽', + description: '본죽 20,000원 이상 주문 시 5,000원 할인을 제공합니다.', + period: '2025.05.01 ~ 2025.06.30', + imageUrl: 'assets/logo/heafit-logo2.png', + tags: ['5,000원 할인', '본죽', '한식'], + paymentMethod: '신한카드, 삼성카드', + discountRate: 25.0, + ), + BenefitDetail( + title: '교촌치킨 10% 할인', + company: '교촌치킨', + description: '교촌치킨 모든 메뉴 10% 할인 혜택을 제공합니다.', + period: '2025.05.15 ~ 2025.07.15', + imageUrl: 'assets/logo/heafit-logo2.png', + tags: ['10% 할인', '교촌치킨', '치킨'], + paymentMethod: '현대카드, 롯데카드', + discountRate: 10.0, + ), + ], + '중식': [ + BenefitDetail( + title: '홍콩반점 5% 할인', + company: '홍콩반점', + description: '홍콩반점 모든 메뉴 5% 할인 혜택을 제공합니다.', + period: '2025.05.01 ~ 2025.06.30', + imageUrl: 'assets/logo/heafit-logo2.png', + tags: ['5% 할인', '홍콩반점', '중식'], + paymentMethod: '신한카드, 삼성카드', + discountRate: 5.0, + ), + ], + '카페/디저트': [ + BenefitDetail( + title: '스타벅스 아메리카노 1+1', + company: '스타벅스', + description: '스타벅스 아메리카노 구매 시 1잔 더 제공합니다.', + period: '2025.05.10 ~ 2025.05.20', + imageUrl: 'assets/logo/heafit-logo2.png', + tags: ['1+1', '스타벅스', '아메리카노'], + paymentMethod: '현대카드, 삼성카드', + discountRate: 50.0, + ), + BenefitDetail( + title: '투썸플레이스 디저트 30% 할인', + company: '투썸플레이스', + description: '투썸플레이스 디저트 메뉴 30% 할인 혜택을 제공합니다.', + period: '2025.05.01 ~ 2025.06.15', + imageUrl: 'assets/logo/heafit-logo2.png', + tags: ['30% 할인', '투썸플레이스', '디저트'], + paymentMethod: '신한카드, KB국민카드', + discountRate: 30.0, + ), + ], + '패션/의류': [ + BenefitDetail( + title: '무신사 신규회원 15% 할인', + company: '무신사', + description: '무신사 신규회원 가입 시 15% 할인 쿠폰을 제공합니다.', + period: '2025.05.01 ~ 2025.06.30', + imageUrl: 'assets/logo/heafit-logo2.png', + tags: ['15% 할인', '무신사', '패션/의류'], + paymentMethod: '전체 결제수단', + discountRate: 15.0, + ), + ], + '영화': [ + BenefitDetail( + title: 'CGV 영화 티켓 30% 할인', + company: 'CGV', + description: 'CGV 영화 티켓 구매 시 30% 할인 혜택을 드립니다.', + period: '2025.05.05 ~ 2025.06.04', + imageUrl: 'assets/logo/heafit-logo2.png', + tags: ['30% 할인', 'CGV', '영화'], + paymentMethod: 'SKT 멤버십, 현대카드', + discountRate: 30.0, + ), + BenefitDetail( + title: '메가박스 1+1 이벤트', + company: '메가박스', + description: '메가박스 영화 티켓 1장 구매 시 1장 무료 혜택을 드립니다.', + period: '2025.05.01 ~ 2025.05.15', + imageUrl: 'assets/logo/heafit-logo2.png', + tags: ['1+1', '메가박스', '영화'], + paymentMethod: '삼성카드, KB국민카드', + discountRate: 50.0, + ), + ], + '대중교통': [ + BenefitDetail( + title: '지하철 청소년 요금 할인', + company: '서울교통공사', + description: '청소년 교통카드 사용 시 지하철 요금 20% 할인', + period: '2025.01.01 ~ 2025.12.31', + imageUrl: 'assets/logo/heafit-logo2.png', + tags: ['20% 할인', '지하철', '청소년'], + paymentMethod: '교통카드', + discountRate: 20.0, + ), + ], + }; + + // 알림 데이터 + final List> _notifications = [ { - 'title': '스타벅스 50% 할인', - 'description': '신한카드로 결제 시 최대 5,000원 할인', - 'period': '2023-05-01 ~ 2023-06-30', - 'tags': ['신한카드', '50%', '할인'], + 'type': '혜택 사용 여부', + 'title': '일정에 담아두신 혜택을 사용하셨나요?', + 'description': + '일정에 담아두신 혜택에 대한 사용 여부를 알려주세요!\nHeafit이 캘린더에 확인하기 쉽게 정리해드릴게요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '1일 전', + 'discount_amount': 2500.0, + 'benefit_name': '버거킹 할인 혜택', }, { - 'title': '스타벅스 1+1', - 'description': '아메리카노 주문 시 케이크 1개 무료', - 'period': '2023-05-15 ~ 2023-05-31', - 'tags': ['1+1', '아메리카노', '케이크'], + 'type': '일정 변경 제안', + 'title': '일정을 변경해보는 건 어떨까요?', + 'description': + '5일에 예정되어 있던 버거킹 일정을 12일로 바꾸는 건 어떨까요? 12일부터 버거킹의 와퍼 소고기 버거를 2,500원 할인해주는 행사가 있어요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '3일 전', + 'from_date': 5, + 'to_date': 12, }, { - 'title': '스타벅스 사이렌 오더 추가 할인', - 'description': '사이렌 오더로 주문 시 10% 추가 할인', - 'period': '2023-05-10 ~ 2023-06-10', - 'tags': ['사이렌 오더', '10%', '추가 할인'], + 'type': '일정 변경 제안', + 'title': '일정을 변경해보는 건 어떨까요?', + 'description': + '5일에 예정되어 있던 버거킹 일정을 12일로 바꾸는 건 어떨까요? 12일부터 버거킹의 와퍼 소고기 버거를 2,500원 할인해주는 행사가 있어요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '4일 전', + 'from_date': 5, + 'to_date': 12, + }, + { + 'type': '관심 카테고리 혜택', + 'title': '마감일이 다가와요!', + 'description': '버거킹의 와퍼 소고기 버거를 2,500원 할인된 가격으로 만나보세요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '10일 전', + 'category': '음식', }, ]; + // 알림 화면 표시 상태 설정 메서드 + void _setShowNotifications(bool value) { + setState(() { + _showNotifications = value; + }); + + // 콜백 호출 + widget.onNotificationStateChanged?.call(value); + } + + @override + void initState() { + super.initState(); + + // 초기 카테고리 설정 + if (widget.initialCategory != null) { + for (int i = 0; i < _mainCategories.length; i++) { + if (_mainCategories[i]['name'] == widget.initialCategory) { + _selectedCategoryIndex = i; + break; + } + } + } + + // 초기 서브 카테고리 설정 + if (widget.initialSubCategory != null) { + _selectedSubCategory = widget.initialSubCategory; + } + + // 즐겨찾기 필터 설정 + _showFavoritesOnly = widget.showFavoritesOnly; + } + + // 특정 서브 카테고리의 혜택 목록을 가져오는 메서드 + List _getBenefitsForSubCategory(String subCategory) { + if (_benefitsList.containsKey(subCategory)) { + List benefits = _benefitsList[subCategory]!; + if (_showFavoritesOnly) { + return benefits.where((benefit) => benefit.isFavorite).toList(); + } + return benefits; + } + return []; + } + @override Widget build(BuildContext context) { + // 알림 화면이 활성화된 경우 + if (_showNotifications) { + return _buildNotificationsScreen(); + } + + // 상세 화면 표시 + if (_showBenefitDetail && _selectedBenefit != null) { + return _buildBenefitDetailScreen(); + } + return Scaffold( - appBar: AppBar(title: const Text('카테고리'), elevation: 0), + backgroundColor: Colors.white, body: Column( children: [ - // 메인 카테고리 목록 + // 카테고리 네비게이션바 + _buildMainCategoryTabs(), + + // 서브 카테고리 표시 + if (_selectedSubCategory == null) + _buildSubCategoryGrid() + else + // 서브 카테고리의 혜택 목록 표시 + _buildBenefitsList(_selectedSubCategory!), + ], + ), + ); + } + + // 메인 카테고리 탭 구현 + Widget _buildMainCategoryTabs() { + return Container( + height: 50, + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, // 중앙 정렬 + children: [ Container( - height: 80, - padding: const EdgeInsets.symmetric(vertical: 8), + width: MediaQuery.of(context).size.width * 0.8, // 화면 너비의 80% child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: _mainCategories.length, itemBuilder: (context, index) { - bool isSelected = _selectedCategoryIndex == index; + final isSelected = _selectedCategoryIndex == index; return GestureDetector( onTap: () { setState(() { _selectedCategoryIndex = index; + _selectedSubCategory = null; // 서브 카테고리 선택 초기화 }); }, child: Container( - width: 80, - margin: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 20), + alignment: Alignment.center, decoration: BoxDecoration( - color: - isSelected - ? AppTheme.primaryColor - : Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - spreadRadius: 1, - blurRadius: 3, - offset: const Offset(0, 1), - ), - ], - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _mainCategories[index]['icon'], + border: Border( + bottom: BorderSide( color: isSelected - ? Colors.white - : Theme.of(context).colorScheme.onSurface, + ? AppTheme.primaryColor + : Colors.transparent, + width: 2, ), - const SizedBox(height: 4), - Text( - _mainCategories[index]['name'], - style: TextStyle( - fontSize: 12, - color: - isSelected - ? Colors.white - : Theme.of(context).colorScheme.onSurface, - ), - ), - ], + ), + ), + child: Text( + _mainCategories[index]['name'], + style: TextStyle( + color: + isSelected ? AppTheme.primaryColor : Colors.black, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + ), ), ), ); }, ), ), + ], + ), + ); + } + + // 서브 카테고리 그리드 구현 + Widget _buildSubCategoryGrid() { + String currentMainCategory = + _mainCategories[_selectedCategoryIndex]['name']; + List> subCategories = + _subCategories.containsKey(currentMainCategory) + ? _subCategories[currentMainCategory]! + : []; + + return Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 0.8, + ), + itemCount: subCategories.length, + itemBuilder: (context, index) { + return GestureDetector( + onTap: () { + setState(() { + _selectedSubCategory = subCategories[index]['name']; + }); + }, + child: Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: Colors.grey.shade300, width: 1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + subCategories[index]['imageUrl'], + width: 70, + height: 70, + fit: BoxFit.contain, + ), + ), + const SizedBox(height: 10), + Text( + subCategories[index]['name'], + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + }, + ), + ); + } + + // 서브 카테고리 혜택 목록 구현 + Widget _buildBenefitsList(String subCategory) { + List benefits = _getBenefitsForSubCategory(subCategory); + + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 서브 카테고리 헤더 + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Text( + subCategory, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), - // 서브 카테고리와 혜택 목록 + // 혜택 목록 Expanded( child: - _selectedCategoryIndex == 0 - ? _buildAllCategories() - : _buildCategoryDetail(), + benefits.isEmpty + ? const Center(child: Text('등록된 혜택이 없습니다.')) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: benefits.length, + itemBuilder: (context, index) { + return GestureDetector( + onTap: () { + setState(() { + _selectedBenefit = benefits[index]; + _showBenefitDetail = true; + }); + }, + child: Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: Colors.grey.shade300, + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 혜택 이미지 + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + benefits[index].imageUrl, + width: 80, + height: 80, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // 혜택 정보 + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + benefits[index].title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + benefits[index].company, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + benefits[index].period, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ), ), ], ), ); } - Widget _buildAllCategories() { - return GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - childAspectRatio: 0.8, - ), - itemCount: _mainCategories.length - 1, // '전체' 제외 - itemBuilder: (context, index) { - final category = _mainCategories[index + 1]; // '전체' 제외 - return GestureDetector( - onTap: () { + // 혜택 상세 정보 화면 구현 + Widget _buildBenefitDetailScreen() { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () { setState(() { - _selectedCategoryIndex = index + 1; + _showBenefitDetail = false; }); }, - child: Column( - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Icon( - category['icon'], - size: 40, - color: AppTheme.primaryColor, - ), - ), + ), + title: const Text('혜택 상세 정보', style: TextStyle(color: Colors.black)), + backgroundColor: Colors.white, + elevation: 0, + actions: [ + // 즐겨찾기 버튼 + IconButton( + icon: Icon( + _selectedBenefit!.isFavorite ? Icons.star : Icons.star_border, + color: _selectedBenefit!.isFavorite ? Colors.yellow : Colors.grey, + ), + onPressed: () { + setState(() { + _selectedBenefit!.isFavorite = !_selectedBenefit!.isFavorite; + }); + }, + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 혜택 이미지 + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.asset( + _selectedBenefit!.imageUrl, + width: double.infinity, + height: 200, + fit: BoxFit.cover, + ), + ), + const SizedBox(height: 16), + + // 혜택 제목 + Text( + _selectedBenefit!.title, + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + + // 회사명 + Text( + _selectedBenefit!.company, + style: TextStyle(fontSize: 16, color: Colors.grey[700]), + ), + const SizedBox(height: 16), + + // 기간 + Row( + children: [ + const Icon(Icons.calendar_today, size: 16, color: Colors.grey), + const SizedBox(width: 8), + Text( + _selectedBenefit!.period, + style: TextStyle(fontSize: 14, color: Colors.grey[700]), ), + ], + ), + const SizedBox(height: 8), + + // 결제 방법 + Row( + children: [ + const Icon(Icons.credit_card, size: 16, color: Colors.grey), + const SizedBox(width: 8), + Text( + _selectedBenefit!.paymentMethod, + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + ), + const SizedBox(height: 24), + + // 할인율 표시 + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), ), - const SizedBox(height: 8), - Text( - category['name'], - style: const TextStyle(fontWeight: FontWeight.bold), + child: Text( + '${_selectedBenefit!.discountRate.toStringAsFixed(0)}% 할인', + style: TextStyle( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), ), - ], - ), - ); - }, + ), + const SizedBox(height: 24), + + // 상세 설명 + const Text( + '상세 정보', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + _selectedBenefit!.description, + style: const TextStyle(fontSize: 16, height: 1.6), + ), + const SizedBox(height: 24), + + // 태그 + Wrap( + spacing: 8, + runSpacing: 8, + children: + _selectedBenefit!.tags + .map( + (tag) => Chip( + label: Text(tag), + backgroundColor: Colors.grey[200], + ), + ) + .toList(), + ), + const SizedBox(height: 32), + + // 일정에 추가 버튼 + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + _showAddToCalendarDialog(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '일정에 추가하기', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), ); } - Widget _buildCategoryDetail() { - // 선택된 카테고리에 따라 다른 서브 카테고리 표시 - // 여기서는 예시로 카페 카테고리만 구현 - return Column( - children: [ - // 서브 카테고리 (브랜드/체인점) 섹션 - const SectionTitle(title: '브랜드'), - SizedBox( - height: 120, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - scrollDirection: Axis.horizontal, - itemCount: _cafeSubCategories.length, - itemBuilder: (context, index) { - return GestureDetector( - onTap: () { - // 브랜드 선택 시 해당 브랜드의 혜택 표시 - }, - child: Container( - width: 100, - margin: const EdgeInsets.only(right: 12), - child: Column( + // 일정 추가 다이얼로그 + void _showAddToCalendarDialog() { + final TextEditingController titleController = TextEditingController( + text: _selectedBenefit?.title ?? '', + ); + DateTime selectedDate = DateTime.now(); + + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: const Text('일정 추가'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 일정 제목 입력 + TextField( + controller: titleController, + decoration: const InputDecoration( + labelText: '일정 제목', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + + // 날짜 선택 + Row( children: [ - Container( - height: 80, - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - _cafeSubCategories[index]['name'], - textAlign: TextAlign.center, - style: const TextStyle(fontWeight: FontWeight.bold), + const Text('날짜: '), + TextButton( + onPressed: () async { + final DateTime? pickedDate = await showDatePicker( + context: context, + initialDate: selectedDate, + firstDate: DateTime.now(), + lastDate: DateTime(2100), + ); + if (pickedDate != null && + pickedDate != selectedDate) { + setState(() { + selectedDate = pickedDate; + }); + } + }, + child: Text( + '${selectedDate.year}년 ${selectedDate.month}월 ${selectedDate.day}일', + style: const TextStyle( + color: Colors.blue, + fontWeight: FontWeight.bold, ), ), ), - const SizedBox(height: 4), - Text( - _cafeSubCategories[index]['name'], - style: const TextStyle(fontSize: 12), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), ], ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('취소'), ), - ); - }, - ), - ), - - // 혜택 목록 섹션 - const SectionTitle(title: '혜택'), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _benefits.length, - itemBuilder: (context, index) { - final benefit = _benefits[index]; - return Card( - margin: const EdgeInsets.only(bottom: 16), - elevation: 2, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - benefit['title'], - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - benefit['description'], - style: TextStyle( - fontSize: 14, - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.7), + ElevatedButton( + onPressed: () { + // 일정 추가 로직 구현 (백엔드 연동부분은 건드리지 않음) + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${titleController.text} 일정이 ${selectedDate.year}년 ${selectedDate.month}월 ${selectedDate.day}일에 추가되었습니다.', ), + duration: const Duration(seconds: 2), ), - const SizedBox(height: 8), - Row( - children: [ - const Icon( - Icons.calendar_today, - size: 12, - color: Colors.grey, - ), - const SizedBox(width: 4), - Text( - benefit['period'], - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: - (benefit['tags'] as List).map((tag) { - return Chip( - label: Text( - tag, - style: const TextStyle(fontSize: 10), - ), - padding: EdgeInsets.zero, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - backgroundColor: AppTheme.primaryColor - .withOpacity(0.1), - ); - }).toList(), - ), - ], - ), + ); + }, + child: const Text('추가'), ), - ); + ], + ); + }, + ); + }, + ); + } + + // 알림 화면 구현 + Widget _buildNotificationsScreen() { + return WillPopScope( + onWillPop: () async { + _setShowNotifications(false); + return false; + }, + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () { + _setShowNotifications(false); }, ), + elevation: 0, + backgroundColor: Colors.white, ), - ], + body: ListView.separated( + itemCount: _notifications.length, + separatorBuilder: + (context, index) => Divider( + height: 1, + color: const Color(0xFFECECEC), + thickness: 1, + indent: 7, + endIndent: 7, + ), + itemBuilder: (context, index) { + final notification = _notifications[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: CircleAvatar( + backgroundColor: Colors.grey[200], + child: ClipOval( + child: Image.asset( + notification['icon'], + width: 40, + height: 40, + fit: BoxFit.cover, + ), + ), + ), + title: Text( + notification['title'], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + notification['description'], + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 4), + Text( + notification['days_ago'], + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + isThreeLine: true, + ); + }, + ), + ), ); } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 151b370..1e6d4f8 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,17 +1,32 @@ import 'package:flutter/material.dart'; import 'package:heafit/constants/theme.dart'; -import 'package:heafit/widgets/benefit_card.dart'; -import 'package:heafit/widgets/category_item.dart'; -import 'package:heafit/widgets/section_title.dart'; +import 'package:heafit/screens/statistics_screen.dart'; +import 'package:heafit/screens/calendar_screen.dart'; +import 'package:heafit/screens/category_screen.dart'; + +// 알림 상태 변경 콜백 타입 정의 +typedef NotificationStateCallback = void Function(bool isShowing); class HomeScreen extends StatefulWidget { - const HomeScreen({Key? key}) : super(key: key); + // 알림 상태 변경 콜백 + final NotificationStateCallback? onNotificationStateChanged; + + const HomeScreen({super.key, this.onNotificationStateChanged}); @override - State createState() => _HomeScreenState(); + State createState() => HomeScreenState(); } -class _HomeScreenState extends State { +class HomeScreenState extends State { + // 알림 화면 표시 여부 + bool _showNotifications = false; + + // 선택된 알림 인덱스 (알림 상세 UI 표시용) + int? _selectedNotificationIndex; + + // 사용 여부 버튼 표시 여부 + bool _showUsageButtons = false; + final PageController _pageController = PageController( viewportFraction: 0.85, initialPage: 0, @@ -22,42 +37,101 @@ class _HomeScreenState extends State { { 'title': '스타벅스 50% 할인', 'description': '신한카드로 결제 시 최대 5,000원 할인', - 'imageUrl': 'assets/images/starbucks.jpg', + 'imageUrl': 'assets/logo/heafit-logo2.png', 'period': '2023-05-01 ~ 2023-06-30', + 'discount': '-2,500원', + 'category': '음식', + 'subcategory': '카페', }, { 'title': '배달의민족 3,000원 할인', 'description': '2만원 이상 주문 시 네이버페이 결제 할인', - 'imageUrl': 'assets/images/baemin.jpg', + 'imageUrl': 'assets/logo/heafit-logo2.png', 'period': '2023-05-15 ~ 2023-05-31', + 'discount': '-3,000원', + 'category': '음식', + 'subcategory': '배달', }, { 'title': 'CGV 영화 1+1', 'description': '토스로 결제 시 동반 1인 무료', - 'imageUrl': 'assets/images/cgv.jpg', + 'imageUrl': 'assets/logo/heafit-logo2.png', 'period': '2023-05-10 ~ 2023-06-10', + 'discount': '-12,000원', + 'category': '엔터테인먼트', + 'subcategory': '영화', }, ]; final List> _categories = [ - {'name': '카페', 'icon': Icons.coffee}, - {'name': '음식점', 'icon': Icons.restaurant}, + {'name': '음식', 'icon': Icons.restaurant}, {'name': '쇼핑', 'icon': Icons.shopping_bag}, - {'name': '영화', 'icon': Icons.movie}, - {'name': '뷰티', 'icon': Icons.face}, - {'name': '여행', 'icon': Icons.flight}, + {'name': '교통', 'icon': Icons.directions_car}, + {'name': '엔터테인먼트', 'icon': Icons.movie}, ]; final List> _aiSuggestions = [ { - 'title': '일정 변경 제안: 내일 점심', - 'description': '내일 대신 모레 스타벅스 방문 시 추가 30% 할인', - 'imageUrl': 'assets/images/calendar_change.jpg', + 'title': '일정 변경 제안', + 'description': + '5일에 예정되어 있던 버거킹 일정을 12일로 바꾸는 건 어떨까요? 12일부터 버거킹의 와퍼 소고기 버거를 2,500원 할인해주는 행사가 있어요!', + 'imageUrl': 'assets/logo/heafit-logo2.png', + 'days_ago': '3일 전', + 'from_date': 5, + 'to_date': 12, + }, + { + 'title': '일정 변경 제안', + 'description': + '5일에 예정되어 있던 버거킹 일정을 12일로 바꾸는 건 어떨까요? 12일부터 버거킹의 와퍼 소고기 버거를 2,500원 할인해주는 행사가 있어요!', + 'imageUrl': 'assets/logo/heafit-logo2.png', + 'days_ago': '4일 전', + 'from_date': 5, + 'to_date': 12, + }, + ]; + + // 절약한 금액 + double _savedAmount = 48500; + + final List> _notifications = [ + { + 'type': '혜택 사용 여부', + 'title': '일정에 담아두신 혜택을 사용하셨나요?', + 'description': + '일정에 담아두신 혜택에 대한 사용 여부를 알려주세요!\nBenefit plus가 캘린더에 확인하기 쉽게 정리해드릴게요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '1일 전', + 'discount_amount': 2500.0, + 'benefit_name': '버거킹 할인 혜택', + }, + { + 'type': '일정 변경 제안', + 'title': '일정을 변경해보는 건 어떨까요?', + 'description': + '5일에 예정되어 있던 버거킹 일정을 12일로 바꾸는 건 어떨까요? 12일부터 버거킹의 와퍼 소고기 버거를 2,500원 할인해주는 행사가 있어요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '3일 전', + 'from_date': 5, + 'to_date': 12, + }, + { + 'type': '일정 변경 제안', + 'title': '일정을 변경해보는 건 어떨까요?', + 'description': + '5일에 예정되어 있던 버거킹 일정을 12일로 바꾸는 건 어떨까요? 12일부터 버거킹의 와퍼 소고기 버거를 2,500원 할인해주는 행사가 있어요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '4일 전', + 'from_date': 5, + 'to_date': 12, }, { - 'title': '두 일정 합치기 제안', - 'description': '내일 영화 관람과 식사를 CGV 콤보로 합치면 50% 할인', - 'imageUrl': 'assets/images/combine_events.jpg', + 'type': '관심 카테고리 혜택', + 'title': '마감일이 다가와요!', + 'description': '버거킹의 와퍼 소고기 버거를 2,500원 할인된 가격으로 만나보세요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '10일 전', + 'category': '음식', }, ]; @@ -80,136 +154,613 @@ class _HomeScreenState extends State { super.dispose(); } + // 알림 화면 표시 상태 설정 메서드 + void _setShowNotifications(bool value) { + setState(() { + _showNotifications = value; + _selectedNotificationIndex = null; + _showUsageButtons = false; + }); + + // 콜백 호출 + widget.onNotificationStateChanged?.call(value); + } + + // 혜택 사용 여부 처리 + void _handleBenefitUsage(bool used) { + if (_selectedNotificationIndex == null) return; + + // 사용함을 선택한 경우 아낀 금액에 반영 + if (used) { + final discountAmount = + _notifications[_selectedNotificationIndex!]['discount_amount'] + as double?; + if (discountAmount != null) { + setState(() { + _savedAmount += discountAmount; + }); + } + + // 사용 완료 메시지 표시 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${_notifications[_selectedNotificationIndex!]['benefit_name']} 사용이 완료되었습니다!', + ), + duration: const Duration(seconds: 2), + ), + ); + } + + // 알림 제거 및 상태 초기화 + setState(() { + _notifications.removeAt(_selectedNotificationIndex!); + _selectedNotificationIndex = null; + _showUsageButtons = false; + }); + } + + // 혜택 카테고리로 이동 + void _navigateToCategoryScreen(String category) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CategoryScreen(initialCategory: category), + ), + ); + } + + // 캘린더 화면으로 이동 + void _navigateToCalendarScreen({int? fromDate, int? toDate}) { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => CalendarScreen( + highlightDates: [ + if (fromDate != null) fromDate, + if (toDate != null) toDate, + ], + ), + ), + ); + } + + // 관심 카테고리 혜택 화면으로 이동 + void _navigateToFavoriteCategoryScreen(String category) { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => CategoryScreen( + initialCategory: category, + showFavoritesOnly: true, + ), + ), + ); + } + @override Widget build(BuildContext context) { + // 알림 화면이 활성화된 경우 + if (_showNotifications) { + return _buildNotificationsScreen(); + } + + // 메인 홈 화면 return Scaffold( - appBar: AppBar( - title: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset('assets/logo/heafit-logo.png', width: 36, height: 36), - const SizedBox(width: 10), - const Text( - 'HEAFIT', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22), - ), - ], - ), - centerTitle: true, - elevation: 0, - ), + backgroundColor: Colors.white, body: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 아낀 금액 요약 카드 + _buildSavingsSummaryCard(), + // 나를 위한 혜택 섹션 - const SectionTitle(title: '나를 위한 혜택'), + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + '나를 위한 혜택', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ), + SizedBox( - height: 180, - child: PageView.builder( - controller: _pageController, + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: _benefits.length, itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 5), - child: BenefitCard( - title: _benefits[index]['title'], - description: _benefits[index]['description'], - imageUrl: _benefits[index]['imageUrl'], - period: _benefits[index]['period'], - ), + return GestureDetector( + onTap: () { + _navigateToCategoryScreen(_benefits[index]['category']); + }, + child: _buildBenefitCard(_benefits[index]), ); }, ), ), - const SizedBox(height: 8), - // 페이지 인디케이터 - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate( - _benefits.length, - (index) => Container( - margin: const EdgeInsets.symmetric(horizontal: 4), - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: - _currentPage == index - ? AppTheme.primaryColor - : Colors.grey.withOpacity(0.3), - ), - ), - ), - ), + const SizedBox(height: 20), // 카테고리별 혜택 섹션 - const SectionTitle(title: '카테고리별 혜택'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 1.0, - ), + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + '카테고리별 혜택', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ), + + SizedBox( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: _categories.length, itemBuilder: (context, index) { - return CategoryItem( - name: _categories[index]['name'], - icon: _categories[index]['icon'], + return GestureDetector( + onTap: () { + _navigateToCategoryScreen(_categories[index]['name']); + }, + child: _buildCategoryCard(_categories[index]), ); }, ), ), + const SizedBox(height: 20), // AI 일정 변경 제안 섹션 - const SectionTitle(title: 'AI 일정 변경 제안'), + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + 'AI 일정 변경 제안', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ), + ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: _aiSuggestions.length, itemBuilder: (context, index) { - return Card( - margin: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - elevation: 2, - child: ListTile( - contentPadding: const EdgeInsets.all(12), - leading: CircleAvatar( - backgroundColor: AppTheme.primaryColor, - child: const Icon( - Icons.calendar_today, - color: Colors.white, - ), + return GestureDetector( + onTap: () { + _navigateToCalendarScreen( + fromDate: _aiSuggestions[index]['from_date'], + toDate: _aiSuggestions[index]['to_date'], + ); + }, + child: _buildAISuggestionCard(_aiSuggestions[index]), + ); + }, + ), + + const SizedBox(height: 20), + ], + ), + ), + ); + } + + // 알림 화면 구현 + Widget _buildNotificationsScreen() { + return WillPopScope( + onWillPop: () async { + _setShowNotifications(false); + return false; + }, + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () { + _setShowNotifications(false); + }, + ), + elevation: 0, + backgroundColor: Colors.white, + ), + body: ListView.separated( + itemCount: _notifications.length, + separatorBuilder: + (context, index) => Divider( + height: 1, + color: const Color(0xFFECECEC), + thickness: 1, + indent: 7, + endIndent: 7, + ), + itemBuilder: (context, index) { + final notification = _notifications[index]; + final isUsageType = notification['type'] == '혜택 사용 여부'; + final isSelected = _selectedNotificationIndex == index; + + return GestureDetector( + onTap: () { + // 각 알림 타입에 따른 동작 처리 + if (isUsageType) { + setState(() { + // 이미 선택된 알림을 다시 탭하면 버튼 토글 + if (isSelected) { + _showUsageButtons = !_showUsageButtons; + } else { + _selectedNotificationIndex = index; + _showUsageButtons = true; + } + }); + } else if (notification['type'] == '일정 변경 제안') { + _navigateToCalendarScreen( + fromDate: notification['from_date'], + toDate: notification['to_date'], + ); + } else if (notification['type'] == '관심 카테고리 혜택') { + _navigateToFavoriteCategoryScreen(notification['category']); + } + }, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, ), - title: Text( - _aiSuggestions[index]['title'], - style: const TextStyle(fontWeight: FontWeight.bold), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(notification['icon']), + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + notification['type'], + style: const TextStyle( + color: Color(0xFF5E5E5E), + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + Text( + notification['days_ago'], + style: const TextStyle( + color: Color(0xFF5E5E5E), + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + notification['title'], + style: const TextStyle( + color: Colors.black, + fontSize: 20, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 8), + Text( + notification['description'], + style: const TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ], ), - subtitle: Padding( - padding: const EdgeInsets.only(top: 4), - child: Text(_aiSuggestions[index]['description']), + ), + // 혜택 사용 여부 버튼 (선택된 경우에만 표시) + if (isUsageType && isSelected && _showUsageButtons) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // "사용했어요" 버튼 + SizedBox( + width: 120, + child: ElevatedButton( + onPressed: () => _handleBenefitUsage(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('사용했어요'), + ), + ), + const SizedBox(width: 16), + // "사용 안했어요" 버튼 + SizedBox( + width: 120, + child: OutlinedButton( + onPressed: () => _handleBenefitUsage(false), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.grey, + side: const BorderSide(color: Colors.grey), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('사용 안했어요'), + ), + ), + ], + ), ), - trailing: const Icon(Icons.arrow_forward_ios, size: 16), - onTap: () { - // 일정 변경 상세 페이지로 이동 - }, + ], + ), + ); + }, + ), + ), + ); + } + + // 아낀 금액 요약 카드 + Widget _buildSavingsSummaryCard() { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const StatisticsScreen()), + ); + }, + child: Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.primaryColor, + AppTheme.primaryColor.withOpacity(0.7), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '이번 달 아낀 금액', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _savedAmount.toStringAsFixed(0), + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.bold, ), - ); - }, + ), + const SizedBox(width: 4), + const Text( + '원', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + children: [ + Icon(Icons.arrow_upward, color: Colors.white, size: 14), + SizedBox(width: 2), + Text( + '15%', + style: TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + ), + ], ), - const SizedBox(height: 20), ], ), ), ); } + + // 혜택 카드 위젯 + Widget _buildBenefitCard(Map benefit) { + return Container( + width: 240, + margin: const EdgeInsets.only(right: 16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFD9D9D9)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(9), + topRight: Radius.circular(9), + ), + child: Container( + color: Colors.grey[200], + child: Center( + child: + benefit['imageUrl'] != null + ? Image.asset( + benefit['imageUrl'], + width: double.infinity, + fit: BoxFit.cover, + ) + : Icon( + Icons.image, + size: 40, + color: Colors.grey[400], + ), + ), + ), + ), + ), + Container( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + benefit['title'], + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + benefit['discount'] ?? '', + style: const TextStyle(fontSize: 10, color: Colors.black), + ), + ], + ), + ), + ], + ), + ); + } + + // 카테고리 카드 위젯 + Widget _buildCategoryCard(Map category) { + return Container( + width: 131, + margin: const EdgeInsets.only(right: 16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFD9D9D9)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(category['icon'], size: 40, color: AppTheme.primaryColor), + const SizedBox(height: 12), + Text( + category['name'], + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400), + ), + ], + ), + ); + } + + // AI 일정 변경 제안 카드 위젯 + Widget _buildAISuggestionCard(Map suggestion) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFD9D9D9)), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.asset( + suggestion['imageUrl'], + width: 100, + height: 35, + fit: BoxFit.contain, + ), + ], + ), + ), + ), + Container(width: 1, height: 90, color: const Color(0xFFD9D9D9)), + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '재료부터 다른 건강한 베이커리 뚜레쥬르', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w400), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + const Text( + '1천원당 50원 할인', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.w400), + ), + ], + ), + ), + ), + ], + ), + ); + } } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 086ce5d..7248cd9 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -6,7 +6,7 @@ import 'package:heafit/screens/calendar_screen.dart'; import 'package:heafit/screens/profile_screen.dart'; class MainScreen extends StatefulWidget { - const MainScreen({Key? key}) : super(key: key); + const MainScreen({super.key}); @override State createState() => _MainScreenState(); @@ -17,17 +17,108 @@ class _MainScreenState extends State int _currentIndex = 0; final PageController _pageController = PageController(); + // 알림창 표시 여부 + bool _showingNotifications = false; + // 각 탭에 해당하는 화면들 - final List _screens = [ - const HomeScreen(), - const CategoryScreen(), - const CalendarScreen(), - const ProfileScreen(), + late final List _screens; + + // 알림 데이터 + final List> _notifications = [ + { + 'type': '혜택 사용 여부', + 'title': '일정에 담아두신 혜택을 사용하셨나요?', + 'description': + '일정에 담아두신 혜택에 대한 사용 여부를 알려주세요!\nHeafit이 캘린더에 확인하기 쉽게 정리해드릴게요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '1일 전', + 'discount_amount': 2500.0, + 'benefit_name': '버거킹 할인 혜택', + }, + { + 'type': '일정 변경 제안', + 'title': '일정을 변경해보는 건 어떨까요?', + 'description': + '5일에 예정되어 있던 버거킹 일정을 12일로 바꾸는 건 어떨까요? 12일부터 버거킹의 와퍼 소고기 버거를 2,500원 할인해주는 행사가 있어요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '3일 전', + 'from_date': 5, + 'to_date': 12, + }, + { + 'type': '일정 변경 제안', + 'title': '일정을 변경해보는 건 어떨까요?', + 'description': + '5일에 예정되어 있던 버거킹 일정을 12일로 바꾸는 건 어떨까요? 12일부터 버거킹의 와퍼 소고기 버거를 2,500원 할인해주는 행사가 있어요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '4일 전', + 'from_date': 5, + 'to_date': 12, + }, + { + 'type': '관심 카테고리 혜택', + 'title': '마감일이 다가와요!', + 'description': '버거킹의 와퍼 소고기 버거를 2,500원 할인된 가격으로 만나보세요!', + 'icon': 'assets/logo/heafit-logo2.png', + 'days_ago': '10일 전', + 'category': '음식', + }, ]; + // 선택된 알림 인덱스 + int? _selectedNotificationIndex; + + // 사용 여부 버튼 표시 여부 + bool _showUsageButtons = false; + + // 혜택 사용 여부 처리 + void _handleBenefitUsage(bool used) { + if (_selectedNotificationIndex == null) return; + + // 사용함을 선택한 경우 아낀 금액에 반영 (여기서는 표시만 해주고 실제 로직은 연결하지 않음) + if (used) { + // 사용 완료 메시지 표시 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${_notifications[_selectedNotificationIndex!]['benefit_name']} 사용이 완료되었습니다!', + ), + duration: const Duration(seconds: 2), + ), + ); + } + + // 알림 제거 및 상태 초기화 + setState(() { + _notifications.removeAt(_selectedNotificationIndex!); + _selectedNotificationIndex = null; + _showUsageButtons = false; + }); + } + @override void initState() { super.initState(); + + // HomeScreen에서 알림 표시 상태가 변경될 때 콜백 함수를 전달 + _screens = [ + HomeScreen( + onNotificationStateChanged: (isShowing) { + setState(() { + _showingNotifications = isShowing; + }); + }, + ), + CategoryScreen( + onNotificationStateChanged: (isShowing) { + setState(() { + _showingNotifications = isShowing; + }); + }, + ), + const CalendarScreen(), + const ProfileScreen(), + ]; } @override @@ -52,6 +143,13 @@ class _MainScreenState extends State ); } + // 알림 화면 토글 처리 + void _toggleNotifications() { + setState(() { + _showingNotifications = !_showingNotifications; + }); + } + @override Widget build(BuildContext context) { // 테마 색상 가져오기 @@ -60,53 +158,241 @@ class _MainScreenState extends State final surfaceColor = Theme.of(context).colorScheme.surface; return Scaffold( - body: PageView( - controller: _pageController, - onPageChanged: (index) { - setState(() { - _currentIndex = index; - }); - }, - children: _screens, - physics: const NeverScrollableScrollPhysics(), // 스와이프로 페이지 전환 비활성화 - ), - bottomNavigationBar: Container( - decoration: BoxDecoration( - color: surfaceColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - spreadRadius: 0, - offset: const Offset(0, -2), + appBar: AppBar( + title: Row( + children: [ + Image.asset('assets/logo/heafit-logo2.png', width: 100, height: 50), + const Spacer(), + IconButton( + icon: const Icon( + Icons.notifications_none, + size: 28, + color: Colors.black, + ), + onPressed: _toggleNotifications, ), ], ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildNavItem(0, Icons.home_outlined, Icons.home, '홈'), - _buildNavItem( - 1, - Icons.category_outlined, - Icons.category, - '카테고리', + leadingWidth: 0, + automaticallyImplyLeading: false, + backgroundColor: Colors.white, + elevation: 0, + ), + body: + _showingNotifications + ? _buildNotificationScreen() + : PageView( + controller: _pageController, + onPageChanged: (index) { + setState(() { + _currentIndex = index; + }); + }, + physics: const NeverScrollableScrollPhysics(), + children: _screens, // 스와이프로 페이지 전환 비활성화 + ), + // 알림창이 표시 중이면 하단 네비게이션 바를 숨김 + bottomNavigationBar: + _showingNotifications + ? null + : Container( + decoration: BoxDecoration( + color: surfaceColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + spreadRadius: 0, + offset: const Offset(0, -2), + ), + ], ), - _buildNavItem( - 2, - Icons.calendar_month_outlined, - Icons.calendar_month, - '일정', + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 8.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildNavItem(0, Icons.home_outlined, Icons.home, '홈'), + _buildNavItem( + 1, + Icons.category_outlined, + Icons.category, + '카테고리', + ), + _buildNavItem( + 2, + Icons.calendar_month_outlined, + Icons.calendar_month, + '일정', + ), + _buildNavItem( + 3, + Icons.settings_outlined, + Icons.settings, + '설정', + ), + ], + ), + ), ), - _buildNavItem(3, Icons.person_outline, Icons.person, '프로필'), - ], - ), + ), + ); + } + + // 알림 화면 구현 + Widget _buildNotificationScreen() { + return ListView.separated( + itemCount: _notifications.length, + separatorBuilder: + (context, index) => Divider( + height: 1, + color: const Color(0xFFECECEC), + thickness: 1, + indent: 7, + endIndent: 7, ), - ), - ), + itemBuilder: (context, index) { + final notification = _notifications[index]; + final isUsageType = notification['type'] == '혜택 사용 여부'; + final isSelected = _selectedNotificationIndex == index; + + return GestureDetector( + onTap: () { + // 혜택 사용 여부 알림인 경우 버튼 표시 + if (isUsageType) { + setState(() { + if (_selectedNotificationIndex == index) { + // 이미 선택된 알림을 다시 누르면 버튼 토글 + _showUsageButtons = !_showUsageButtons; + } else { + // 다른 알림을 선택하면 해당 알림으로 변경하고 버튼 표시 + _selectedNotificationIndex = index; + _showUsageButtons = true; + } + }); + } + }, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + backgroundColor: Colors.grey[200], + child: ClipOval( + child: Image.asset( + notification['icon'], + width: 40, + height: 40, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + notification['type'], + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + Text( + notification['days_ago'], + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + notification['title'], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + notification['description'], + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ], + ), + ), + // 혜택 사용 여부 버튼 (선택된 경우에만 표시) + if (isUsageType && isSelected && _showUsageButtons) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // "사용했어요" 버튼 + SizedBox( + width: 120, + child: ElevatedButton( + onPressed: () => _handleBenefitUsage(true), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('사용했어요'), + ), + ), + const SizedBox(width: 16), + // "사용 안했어요" 버튼 + SizedBox( + width: 120, + child: OutlinedButton( + onPressed: () => _handleBenefitUsage(false), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.grey, + side: const BorderSide(color: Colors.grey), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('사용 안했어요'), + ), + ), + ], + ), + ), + ], + ), + ); + }, ); } diff --git a/lib/screens/onboarding_screen.dart b/lib/screens/onboarding_screen.dart index 452cbd2..c5199dc 100644 --- a/lib/screens/onboarding_screen.dart +++ b/lib/screens/onboarding_screen.dart @@ -19,22 +19,22 @@ class _OnboardingScreenState extends State { { 'title': '다양한 혜택 정보', 'description': '일상 속 다양한 혜택과 할인 정보를 한눈에 확인하세요.', - 'image': 'assets/logo/heafit-logo.png', + 'image': 'assets/logo/heafit-logo2.png', }, { 'title': 'AI 맞춤형 스케줄 관리', 'description': 'AI가 당신의 일정에 맞는 혜택을 추천해 드려요.', - 'image': 'assets/logo/heafit-logo.png', + 'image': 'assets/logo/heafit-logo2.png', }, { 'title': '혜택 기록 및 분석', 'description': '사용한 혜택을 기록하고 얼마나 절약했는지 확인하세요.', - 'image': 'assets/logo/heafit-logo.png', + 'image': 'assets/logo/heafit-logo2.png', }, { 'title': '일상 속 자연스러운 사용', 'description': '어떤 상황에서도 쉽게 이용할 수 있는 직관적인 경험을 제공합니다.', - 'image': 'assets/logo/heafit-logo.png', + 'image': 'assets/logo/heafit-logo2.png', }, ]; @@ -51,10 +51,10 @@ class _OnboardingScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - // 로고 + // 로고 - heafit-logo2.png로 변경 Image.asset( - 'assets/logo/heafit-logo.png', - width: 60, + 'assets/logo/heafit-logo2.png', + width: 120, height: 60, ), ], diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 5468962..2a8bbc9 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:heafit/constants/theme.dart'; +import 'package:heafit/services/google_auth_service.dart'; +import 'package:googleapis/calendar/v3.dart' as calendar; +import 'package:shared_preferences/shared_preferences.dart'; class ProfileScreen extends StatefulWidget { const ProfileScreen({Key? key}) : super(key: key); @@ -9,9 +12,15 @@ class ProfileScreen extends StatefulWidget { } class _ProfileScreenState extends State { + final GoogleAuthService _googleAuthService = GoogleAuthService(); bool _isDarkMode = false; bool _showNotifications = true; bool _suggestScheduleChanges = true; + bool _isCalendarConnected = false; + bool _isSyncingCalendars = false; + + // 동기화할 캘린더 목록 + final List _selectedCalendarsForSync = []; // 결제 수단 리스트 final List> _paymentMethods = [ @@ -46,6 +55,431 @@ class _ProfileScreenState extends State { ]; final List _selectedCategories = ['카페', '영화', '쇼핑']; + @override + void initState() { + super.initState(); + _checkCalendarConnection(); + } + + Future _checkCalendarConnection() async { + await _googleAuthService.init(); + setState(() { + _isCalendarConnected = _googleAuthService.isCalendarConnected; + }); + } + + // 구글 계정으로 로그인하고 캘린더 연결 + Future _connectGoogleCalendar() async { + final success = await _googleAuthService.signIn(); + + setState(() { + _isCalendarConnected = success; + }); + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('구글 캘린더가 성공적으로 연결되었습니다!'), + backgroundColor: AppTheme.primaryColor, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('구글 캘린더 연결에 실패했습니다. 다시 시도해주세요.'), + backgroundColor: Colors.red, + ), + ); + } + } + + // 구글 계정 로그아웃 + Future _disconnectGoogleCalendar() async { + await _googleAuthService.signOut(); + + setState(() { + _isCalendarConnected = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('구글 계정 연결이 해제되었습니다.'), + backgroundColor: Colors.grey, + ), + ); + } + + // 캘린더 동기화 설정 다이얼로그 + void _showSyncCalendarDialog() { + if (!_isCalendarConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('구글 계정에 연결되어 있지 않습니다. 먼저 계정을 연결해주세요.'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + // 현재 동기화 설정 불러오기 + _selectedCalendarsForSync.clear(); + _selectedCalendarsForSync.addAll(_googleAuthService.syncSourceCalendarIds); + + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: const Text('일정 동기화 설정'), + content: SizedBox( + width: double.maxFinite, + height: 300, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Heafit 캘린더에 동기화할 캘린더', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + const Text( + '선택한 캘린더의 일정이 앱 전용 캘린더에 복사되고 지속적으로 동기화됩니다.', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 16), + Expanded( + child: + _googleAuthService.userCalendars.isEmpty + ? const Center(child: Text('캘린더 목록을 불러오는 중...')) + : ListView.builder( + itemCount: + _googleAuthService.userCalendars.length, + itemBuilder: (context, index) { + final calendar = + _googleAuthService.userCalendars[index]; + + // Heafit 캘린더는 제외 + if (calendar.id == + _googleAuthService.heafitCalendarId) { + return const SizedBox.shrink(); + } + + final calendarId = calendar.id ?? ''; + final isSelected = _selectedCalendarsForSync + .contains(calendarId); + + // 캘린더 색상 표시 + Color calendarColor = AppTheme.primaryColor; + if (calendar.backgroundColor != null) { + try { + final colorCode = calendar + .backgroundColor! + .replaceFirst('#', '0xFF'); + calendarColor = Color( + int.parse(colorCode), + ); + } catch (e) { + debugPrint('색상 변환 오류: $e'); + } + } + + return CheckboxListTile( + title: Text(calendar.summary ?? '이름 없음'), + subtitle: Text( + calendar.id == 'primary' ? '기본 캘린더' : '', + ), + secondary: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: calendarColor, + shape: BoxShape.circle, + ), + ), + value: isSelected, + activeColor: AppTheme.primaryColor, + onChanged: (value) { + setState(() { + if (value == true) { + _selectedCalendarsForSync.add( + calendarId, + ); + } else { + _selectedCalendarsForSync.remove( + calendarId, + ); + } + }); + }, + ); + }, + ), + ), + // 선택된 캘린더 상태 표시 + Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: + _selectedCalendarsForSync.isEmpty + ? const Text( + '선택된 캘린더가 없습니다. 동기화를 해제합니다.', + style: TextStyle( + color: Colors.blue, + fontSize: 13, + ), + ) + : Text( + '${_selectedCalendarsForSync.length}개의 캘린더가 선택됨', + style: TextStyle( + color: AppTheme.primaryColor, + fontSize: 13, + ), + ), + ), + ], + ), + ), + actions: [ + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('취소'), + ), + ), + Expanded( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + if (_selectedCalendarsForSync.isEmpty) { + // 선택된 캘린더가 없을 때 - 동기화 해제 설정 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('모든 캘린더 동기화가 해제되었습니다.'), + backgroundColor: Colors.blue, + duration: Duration(seconds: 2), + ), + ); + _googleAuthService.syncSourceCalendarIds.clear(); + // 저장 처리 + SharedPreferences.getInstance().then((prefs) { + prefs.setStringList( + 'sync_source_calendar_ids', + [], + ); + }); + } else { + // 선택된 캘린더가 있을 때 - 동기화 진행 + _showSyncConfirmationDialog(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: + _selectedCalendarsForSync.isEmpty + ? const Text('동기화 해제') + : const Text('적용'), + ), + ), + ], + ), + ], + ); + }, + ); + }, + ); + } + + // 동기화 확인 다이얼로그 + void _showSyncConfirmationDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('캘린더 동기화 확인'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.sync, color: AppTheme.primaryColor, size: 48), + const SizedBox(height: 16), + const Text( + '선택한 캘린더의 일정을 Heafit 캘린더로 동기화합니다.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + '이후 해당 캘린더에 생성되는 새 일정도 자동으로 동기화되며, 이 정보는 AI 추천 기능에 활용됩니다.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 12), + const Text( + '동기화 범위: 현재 월 및 이전 달의 일정만 동기화됩니다. 삭제된 일정은 자동으로 앱에서도 제거됩니다.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + ], + ), + actions: [ + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('취소'), + ), + ), + Expanded( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + _syncSelectedCalendars(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('동기화 수락'), + ), + ), + ], + ), + ], + ); + }, + ); + } + + // 선택한 캘린더를 Heafit 캘린더로 동기화 + Future _syncSelectedCalendars() async { + if (_selectedCalendarsForSync.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('모든 캘린더 동기화가 해제되었습니다.'), + backgroundColor: Colors.blue, + ), + ); + + // 동기화 설정 초기화 + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList('sync_source_calendar_ids', []); + return; + } + + if (!_isCalendarConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('구글 캘린더 연결이 필요합니다.'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() { + _isSyncingCalendars = true; + }); + + try { + // 동기화 기간 설정: 현재 월 및 이전 월의 일정만 동기화 + final now = DateTime.now(); + // 현재 년도의 1월 1일 (1월이면 전년도의 12월 1일) + final startMonth = now.month > 1 ? 1 : 12; + final startYear = now.month > 1 ? now.year : now.year - 1; + final startTime = DateTime(startYear, startMonth, 1); + + // 현재 월의 마지막 날 + final endTime = DateTime(now.year, now.month + 1, 0); + + final syncedCount = await _googleAuthService.syncCalendarToHeafit( + sourceCalendarIds: _selectedCalendarsForSync, + startTime: startTime, + endTime: endTime, + ); + + setState(() { + _isSyncingCalendars = false; + }); + + // 동기화 완료 알림 + if (syncedCount > 0) { + _showSyncCompletedDialog(syncedCount); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('동기화할 일정이 없습니다.'), + backgroundColor: Colors.orange, + ), + ); + } + } catch (e) { + setState(() { + _isSyncingCalendars = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('동기화 중 오류가 발생했습니다: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + + // 동기화 완료 다이얼로그 + void _showSyncCompletedDialog(int count) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('동기화 완료'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle, color: Colors.green, size: 48), + const SizedBox(height: 16), + Text( + '$count개의 일정이 Heafit 캘린더에 동기화되었습니다', + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + '이후 추가되는 일정도 자동으로 동기화됩니다.\n동기화된 일정은 AI의 일정 변경 추천 및 혜택 추천에 활용됩니다.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + ], + ), + actions: [ + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('확인'), + ), + ), + ], + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -272,11 +706,39 @@ class _ProfileScreenState extends State { _buildSubSectionHeader('캘린더 설정'), ListTile( title: const Text('구글 계정 연결'), - subtitle: Text(_isDarkMode ? '연결됨' : '연결 필요'), - trailing: const Icon(Icons.arrow_forward_ios, size: 16), - onTap: () { - // 구글 계정 연결 기능 - }, + subtitle: Text(_isCalendarConnected ? '연결됨' : '연결 필요'), + trailing: + _isCalendarConnected + ? TextButton( + onPressed: _disconnectGoogleCalendar, + child: const Text( + '로그아웃', + style: TextStyle(color: Colors.red), + ), + ) + : const Icon(Icons.arrow_forward_ios, size: 16), + onTap: _isCalendarConnected ? null : _connectGoogleCalendar, + ), + + // 캘린더 동기화 설정 + ListTile( + title: const Text('캘린더 동기화 설정'), + subtitle: const Text('다른 캘린더의 일정을 Heafit 캘린더로 복사'), + trailing: + _isSyncingCalendars + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + AppTheme.primaryColor, + ), + ), + ) + : const Icon(Icons.arrow_forward_ios, size: 16), + enabled: _isCalendarConnected, + onTap: _showSyncCalendarDialog, ), // 테마 설정 diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 65ce7da..deacd40 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -82,65 +82,35 @@ class _SplashScreenState extends State @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFFFF8E1), // 연한 베이지색 배경 body: Container( + width: double.infinity, + height: double.infinity, decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0xFFFFF8E1), Color(0xFFFFECB3)], + colors: [Colors.white, Color(0xD6FFF6D9)], ), ), - child: Center( - child: AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Column( + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Center( + child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Spacer(flex: 2), - // Heafit 로고 이미지 애니메이션 적용 + // Heafit 로고 이미지 애니메이션 적용 - 원 모양 배경 제거 Transform.scale( scale: _scaleAnimation.value, child: Opacity( opacity: _fadeAnimation.value, - child: Container( - padding: const EdgeInsets.all(24), - width: 220, - height: 220, - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: const Color(0xFFFF7043).withOpacity(0.2), - blurRadius: 20, - spreadRadius: 5, - ), - ], - ), - child: Image.asset( - 'assets/logo/heafit-logo.png', - width: 180, - height: 180, - fit: BoxFit.contain, - ), - ), - ), - ), - const SizedBox(height: 30), - - // 앱 이름 - Opacity( - opacity: _fadeAnimation.value, - child: const Text( - 'Heafit', - style: TextStyle( - fontSize: 48, - fontWeight: FontWeight.bold, - color: Color(0xFFFF7043), // 주황색 텍스트 - letterSpacing: 1.5, + child: Image.asset( + 'assets/logo/heafit-logo.png', + width: 180, + height: 180, + fit: BoxFit.contain, ), ), ), @@ -150,29 +120,28 @@ class _SplashScreenState extends State // 로딩 인디케이터 Opacity( opacity: _fadeAnimation.value, - child: const SizedBox( - width: 40, - height: 40, - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - Color(0xFFFF7043), + child: Column( + children: [ + const SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Color(0xFFFF7043), + ), + strokeWidth: 3, + ), ), - strokeWidth: 3, - ), - ), - ), - const SizedBox(height: 20), - - // 로딩 텍스트 - Opacity( - opacity: _fadeAnimation.value, - child: const Text( - 'LOADING', - style: TextStyle( - fontSize: 14, - color: Colors.grey, - letterSpacing: 2, - ), + const SizedBox(height: 10), + const Text( + 'LOADING', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + letterSpacing: 2, + ), + ), + ], ), ), const SizedBox(height: 30), @@ -185,16 +154,19 @@ class _SplashScreenState extends State child: Text( '캘린더를 연동하여 나만을 위한 혜택을 찾아보세요!', textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, color: Colors.grey), + style: TextStyle( + fontSize: 16, + color: Color(0xFF777777), + ), ), ), ), - const Spacer(), + const SizedBox(height: 60), ], - ); - }, - ), + ), + ); + }, ), ), ); diff --git a/lib/screens/statistics_screen.dart b/lib/screens/statistics_screen.dart new file mode 100644 index 0000000..fa1352b --- /dev/null +++ b/lib/screens/statistics_screen.dart @@ -0,0 +1,596 @@ +import 'package:flutter/material.dart'; +import 'package:heafit/constants/theme.dart'; +import 'package:intl/intl.dart'; + +class StatisticsScreen extends StatefulWidget { + const StatisticsScreen({super.key}); + + @override + State createState() => _StatisticsScreenState(); +} + +class _StatisticsScreenState extends State + with SingleTickerProviderStateMixin { + // 탭 컨트롤러 - 다양한 통계 뷰를 탭으로 전환 + late TabController _tabController; + + // 금액 포맷터 + final NumberFormat _currencyFormat = NumberFormat.currency( + locale: 'ko_KR', + symbol: '', + decimalDigits: 0, + ); + + // 통계 데이터 (실제 앱에서는 API나 로컬 DB에서 가져옴) + final Map> _savingsByMonth = { + '1월': [45000, 38000, 12000, 8000, 15000], + '2월': [38000, 42000, 18000, 5000, 12000], + '3월': [55000, 28000, 15000, 10000, 5000], + '4월': [42000, 35000, 22000, 12000, 8000], + '5월': [48500, 40000, 25000, 15000, 10000], + }; + + final List _categoryColors = [ + Colors.blue, + Colors.red, + Colors.green, + Colors.amber, + Colors.purple, + ]; + + final List _categories = ['카페/식당', '쇼핑', '영화/공연', '교통', '기타']; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('나의 혜택 통계'), + centerTitle: true, + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: '월별 추이'), + Tab(text: '카테고리별'), + Tab(text: '카드/멤버십별'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + _buildMonthlyTrendTab(), + _buildCategoryTab(), + _buildMembershipTab(), + ], + ), + ); + } + + // 월별 추이 탭 + Widget _buildMonthlyTrendTab() { + // 월별 총 절약액 + final List monthlyTotals = + _savingsByMonth.entries + .map((entry) => entry.value.reduce((a, b) => a + b)) + .toList(); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 이번 달 요약 카드 + _buildSummaryCard(), + + const SizedBox(height: 24), + + // 월별 추이 그래프 + const Text( + '월별 절약 추이', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + SizedBox( + height: 200, + child: Container( + color: Colors.grey.shade200, + child: const Center(child: Text('절약 추이 그래프가 표시됩니다')), + ), + ), + + const SizedBox(height: 32), + + // 목표 달성률 + const Text( + '월간 절약 목표 달성률', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + _buildGoalProgressBar(), + + const SizedBox(height: 32), + + // 이번 달 인기 혜택 + const Text( + '이번 달 인기 혜택', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + _buildPopularBenefitsList(), + ], + ), + ); + } + + // 카테고리별 통계 탭 + Widget _buildCategoryTab() { + // 현재 달(5월) 데이터 기준 + final currentMonthData = _savingsByMonth['5월']!; + final totalAmount = currentMonthData.reduce((a, b) => a + b); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '카테고리별 절약 금액', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + // 파이 차트 자리 + SizedBox( + height: 280, + child: Container( + color: Colors.grey.shade200, + child: const Center(child: Text('카테고리별 파이 차트가 표시됩니다')), + ), + ), + + const SizedBox(height: 24), + + // 범례 및 금액 목록 + ...List.generate( + _categories.length, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 16, + height: 16, + color: _categoryColors[index], + ), + const SizedBox(width: 8), + Text( + _categories[index], + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const Spacer(), + Text( + '${_currencyFormat.format(currentMonthData[index])}원', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + + const Divider(height: 32), + + // 총 금액 + Row( + children: [ + const Text( + '총 절약 금액', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const Spacer(), + Text( + '${_currencyFormat.format(totalAmount)}원', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + ], + ), + ], + ), + ); + } + + // 카드/멤버십별 통계 탭 + Widget _buildMembershipTab() { + // 임시 데이터 + final List> membershipData = [ + {'name': '신한카드', 'amount': 20000, 'count': 8}, + {'name': '현대카드', 'amount': 15000, 'count': 5}, + {'name': 'SKT 멤버십', 'amount': 12000, 'count': 4}, + {'name': '네이버페이', 'amount': 8000, 'count': 3}, + {'name': '카카오페이', 'amount': 5000, 'count': 2}, + ]; + + membershipData.sort((a, b) => b['amount'].compareTo(a['amount'])); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '카드/멤버십별 혜택 사용량', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + // 바 차트 자리 + SizedBox( + height: 200, + child: Container( + color: Colors.grey.shade200, + child: const Center(child: Text('카드/멤버십별 바 차트가 표시됩니다')), + ), + ), + + const SizedBox(height: 32), + + // 상세 목록 + const Text( + '카드/멤버십별 혜택 상세', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + ...membershipData + .map( + (data) => Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // 카드/멤버십 아이콘 또는 이니셜 + CircleAvatar( + backgroundColor: AppTheme.primaryColor.withOpacity( + 0.1, + ), + child: Text( + data['name'][0], + style: TextStyle( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 16), + + // 카드/멤버십 정보 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data['name'], + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + '${data['count']}회 사용', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + + // 절약 금액 + Text( + '${_currencyFormat.format(data['amount'])}원', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + ), + ), + ) + .toList(), + ], + ), + ); + } + + // 요약 카드 위젯 + Widget _buildSummaryCard() { + final currentMonthTotal = _savingsByMonth['5월']!.reduce((a, b) => a + b); + final previousMonthTotal = _savingsByMonth['4월']!.reduce((a, b) => a + b); + final percentChange = + ((currentMonthTotal - previousMonthTotal) / previousMonthTotal * 100) + .round(); + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.primaryColor, + AppTheme.primaryColor.withOpacity(0.7), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '5월 절약 금액', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _currencyFormat.format(currentMonthTotal), + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 4), + const Text( + '원', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + percentChange >= 0 + ? Icons.arrow_upward + : Icons.arrow_downward, + color: Colors.white, + size: 14, + ), + const SizedBox(width: 2), + Text( + '${percentChange.abs()}%', + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildSummaryItem('사용 혜택', '12건'), + _buildSummaryItem('절약 횟수', '22회'), + _buildSummaryItem('이용 매장', '8곳'), + ], + ), + ], + ), + ); + } + + // 요약 아이템 위젯 + Widget _buildSummaryItem(String label, String value) { + return Column( + children: [ + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 12), + ), + ], + ); + } + + // 목표 달성률 프로그레스 바 + Widget _buildGoalProgressBar() { + // 목표: 6만원 절약, 현재: 48500원 절약 + const double goalAmount = 60000; + const double currentAmount = 48500; + final double percentage = (currentAmount / goalAmount * 100).clamp(0, 100); + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('5월 목표', style: TextStyle(fontWeight: FontWeight.bold)), + Text( + '${percentage.toInt()}% 달성', + style: TextStyle( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + Stack( + children: [ + // 배경 바 + Container( + height: 20, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(10), + ), + ), + // 진행 바 + Container( + height: 20, + width: + MediaQuery.of(context).size.width * (percentage / 100) - + 32, // 패딩 고려 + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.primaryColor, + AppTheme.primaryColor.withOpacity(0.7), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + borderRadius: BorderRadius.circular(10), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${_currencyFormat.format(currentAmount)}원', + style: const TextStyle(fontSize: 12), + ), + Text( + '목표: ${_currencyFormat.format(goalAmount)}원', + style: const TextStyle(fontSize: 12), + ), + ], + ), + ], + ); + } + + // 인기 혜택 목록 + Widget _buildPopularBenefitsList() { + // 임시 데이터 + final List> popularBenefits = [ + {'title': '스타벅스 아메리카노 1+1', 'saved': 8000, 'usageCount': 4}, + {'title': 'CGV 영화 티켓 30% 할인', 'saved': 12000, 'usageCount': 3}, + {'title': '교촌치킨 10% 할인', 'saved': 6000, 'usageCount': 2}, + ]; + + return Column( + children: + popularBenefits + .map( + (benefit) => Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // 순위 표시 + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: AppTheme.primaryColor, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${popularBenefits.indexOf(benefit) + 1}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 16), + + // 혜택 정보 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + benefit['title'], + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + '${benefit['usageCount']}회 사용', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + + // 절약 금액 + Text( + '${_currencyFormat.format(benefit['saved'])}원', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + ], + ), + ), + ), + ) + .toList(), + ); + } +} diff --git a/lib/services/google_auth_service.dart b/lib/services/google_auth_service.dart new file mode 100644 index 0000000..665b4d9 --- /dev/null +++ b/lib/services/google_auth_service.dart @@ -0,0 +1,728 @@ +import 'package:flutter/material.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:googleapis/calendar/v3.dart' as calendar; +import 'package:googleapis_auth/auth_io.dart'; +import 'package:http/http.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:heafit/config/secrets.dart'; + +/// 구글 인증 및 캘린더 API 연동을 위한 서비스 클래스 +class GoogleAuthService { + static final GoogleAuthService _instance = GoogleAuthService._internal(); + factory GoogleAuthService() => _instance; + GoogleAuthService._internal(); + + final GoogleSignIn _googleSignIn = GoogleSignIn( + scopes: [ + 'email', + 'https://www.googleapis.com/auth/calendar', + 'https://www.googleapis.com/auth/calendar.events', + ], + ); + + GoogleSignInAccount? _currentUser; + calendar.CalendarApi? _calendarApi; + bool _isCalendarConnected = false; + + // 앱 전용 캘린더 ID + String? _heafitCalendarId; + + // 표시할 캘린더 목록 + List _userCalendars = []; + List _visibleCalendarIds = []; + + // 동기화 대상 캘린더 목록 + List _syncSourceCalendarIds = []; + + /// 현재 로그인한 사용자 + GoogleSignInAccount? get currentUser => _currentUser; + + /// 캘린더 API 인스턴스 + calendar.CalendarApi? get calendarApi => _calendarApi; + + /// 캘린더 연결 여부 + bool get isCalendarConnected => _isCalendarConnected; + + /// Heafit 캘린더 ID + String? get heafitCalendarId => _heafitCalendarId; + + /// 사용자의 캘린더 목록 + List get userCalendars => _userCalendars; + + /// 표시할 캘린더 ID 목록 + List get visibleCalendarIds => _visibleCalendarIds; + + /// 동기화 대상 캘린더 ID 목록 + List get syncSourceCalendarIds => _syncSourceCalendarIds; + + /// 초기화 함수 + Future init() async { + // 저장된 로그인 정보 확인 + _currentUser = await _googleSignIn.signInSilently(); + if (_currentUser != null) { + await _checkCalendarConnection(); + if (_isCalendarConnected) { + await _setupCalendarAccess(); + await _loadCalendarSettings(); + await _loadCalendarList(); + } + } + } + + /// 구글 로그인 + Future signIn() async { + try { + final user = await _googleSignIn.signIn(); + if (user == null) return false; + + _currentUser = user; + final success = await _setupCalendarAccess(); + + if (success) { + await _loadCalendarList(); + await _ensureHeafitCalendarExists(); + } + + return success; + } catch (error) { + debugPrint('Google sign in error: $error'); + return false; + } + } + + /// 로그아웃 + Future signOut() async { + await _googleSignIn.signOut(); + _currentUser = null; + _calendarApi = null; + _isCalendarConnected = false; + _userCalendars = []; + _visibleCalendarIds = []; + _syncSourceCalendarIds = []; + + // 저장된 설정 초기화 + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('is_calendar_connected', false); + await prefs.setStringList('visible_calendar_ids', []); + await prefs.setStringList('sync_source_calendar_ids', []); + } + + /// 캘린더 접근 설정 + Future _setupCalendarAccess() async { + try { + final auth = await _currentUser!.authentication; + final credentials = AccessCredentials( + AccessToken( + 'Bearer', + auth.accessToken!, + DateTime.now().toUtc().add(const Duration(hours: 1)), + ), + auth.idToken, + [ + 'https://www.googleapis.com/auth/calendar', + 'https://www.googleapis.com/auth/calendar.events', + ], + ); + + // 시크릿 파일에서 클라이언트 ID 가져오기 + final clientId = Secrets.googleClientId; + + // googleapis_auth의 authenticatedClient 메서드 사용 + final client = authenticatedClient(Client(), credentials); + + _calendarApi = calendar.CalendarApi(client); + + // 캘린더 연결 성공 시 설정 저장 + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('is_calendar_connected', true); + _isCalendarConnected = true; + + return true; + } catch (e) { + debugPrint('Calendar setup error: $e'); + return false; + } + } + + /// 캘린더 연결 여부 확인 + Future _checkCalendarConnection() async { + final prefs = await SharedPreferences.getInstance(); + _isCalendarConnected = prefs.getBool('is_calendar_connected') ?? false; + return _isCalendarConnected; + } + + /// 사용자의 캘린더 목록 로드 + Future _loadCalendarList() async { + if (_calendarApi == null || !_isCalendarConnected) { + return; + } + + try { + final calendarList = await _calendarApi!.calendarList.list(); + _userCalendars = calendarList.items ?? []; + + // Heafit 캘린더가 있는지 확인 + final heafitCalendar = _userCalendars.firstWhere( + (calendar) => + calendar.summary != null && calendar.summary!.startsWith('Heafit'), + orElse: () => calendar.CalendarListEntry(), + ); + + if (heafitCalendar.id != null) { + _heafitCalendarId = heafitCalendar.id; + } + + debugPrint('로드된 캘린더 수: ${_userCalendars.length}'); + } catch (e) { + debugPrint('캘린더 목록 로드 오류: $e'); + } + } + + /// Heafit 캘린더 생성 (없는 경우) + Future _ensureHeafitCalendarExists() async { + if (_calendarApi == null || !_isCalendarConnected) { + return null; + } + + // 이미 Heafit 캘린더가 있는 경우 + if (_heafitCalendarId != null) { + return _heafitCalendarId; + } + + try { + // 사용자 정보 가져오기 + final userName = _currentUser?.displayName ?? '사용자'; + + // Heafit 캘린더 생성 + final newCalendar = calendar.Calendar(); + newCalendar.summary = 'Heafit - ${userName}의 캘린더'; + newCalendar.description = + '${userName}의 Heafit 앱 전용 개인 캘린더입니다. 혜택 정보 및 일정을 관리합니다.'; + newCalendar.timeZone = 'Asia/Seoul'; + + debugPrint('Heafit 캘린더 생성 중: ${newCalendar.summary}'); + final createdCalendar = await _calendarApi!.calendars.insert(newCalendar); + _heafitCalendarId = createdCalendar.id; + debugPrint('Heafit 캘린더 생성 완료: $_heafitCalendarId'); + + // 새로 생성된 캘린더도 표시할 캘린더 목록에 추가 + if (_heafitCalendarId != null) { + await addVisibleCalendar(_heafitCalendarId!); + } + + // 캘린더 목록 다시 로드 + await _loadCalendarList(); + + return _heafitCalendarId; + } catch (e) { + debugPrint('Heafit 캘린더 생성 오류: $e'); + return null; + } + } + + /// 표시할 캘린더 설정 불러오기 + Future _loadCalendarSettings() async { + final prefs = await SharedPreferences.getInstance(); + _visibleCalendarIds = prefs.getStringList('visible_calendar_ids') ?? []; + _syncSourceCalendarIds = + prefs.getStringList('sync_source_calendar_ids') ?? []; + + // 아무것도 선택되지 않았다면 기본적으로 primary 캘린더는 표시 + if (_visibleCalendarIds.isEmpty) { + _visibleCalendarIds.add('primary'); + } + } + + /// 표시할 캘린더 추가 + Future addVisibleCalendar(String calendarId) async { + if (!_visibleCalendarIds.contains(calendarId)) { + _visibleCalendarIds.add(calendarId); + await _saveVisibleCalendars(); + } + } + + /// 표시할 캘린더에서 제거 + Future removeVisibleCalendar(String calendarId) async { + if (_visibleCalendarIds.contains(calendarId)) { + _visibleCalendarIds.remove(calendarId); + await _saveVisibleCalendars(); + } + } + + /// 표시할 캘린더 목록 설정 + Future setVisibleCalendars(List calendarIds) async { + _visibleCalendarIds = List.from(calendarIds); + await _saveVisibleCalendars(); + } + + /// 표시할 캘린더 목록 저장 + Future _saveVisibleCalendars() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList('visible_calendar_ids', _visibleCalendarIds); + } + + /// 특정 캘린더가 표시되는지 확인 + bool isCalendarVisible(String calendarId) { + return _visibleCalendarIds.contains(calendarId); + } + + /// 이벤트를 구글 캘린더에 추가 + Future addEventToCalendar({ + required String title, + required String description, + required DateTime startTime, + required DateTime endTime, + String? location, + String? calendarId, + }) async { + if (_calendarApi == null || !_isCalendarConnected) { + return false; + } + + try { + // 이벤트 생성 + final event = calendar.Event(); + event.summary = title; + event.description = description; + + // 시작 시간 설정 + final start = calendar.EventDateTime(); + start.dateTime = startTime; + start.timeZone = 'Asia/Seoul'; + event.start = start; + + // 종료 시간 설정 + final end = calendar.EventDateTime(); + end.dateTime = endTime; + end.timeZone = 'Asia/Seoul'; + event.end = end; + + // 위치 설정 (선택적) + if (location != null && location.isNotEmpty) { + event.location = location; + } + + // Heafit 캘린더가 없으면 생성 + if (_heafitCalendarId == null) { + await _ensureHeafitCalendarExists(); + } + + // 캘린더에 이벤트 추가 + final targetCalendarId = calendarId ?? _heafitCalendarId ?? 'primary'; + await _calendarApi!.events.insert(event, targetCalendarId); + return true; + } catch (e) { + debugPrint('Add event error: $e'); + return false; + } + } + + /// 캘린더 이벤트 가져오기 + Future> getEvents({ + required DateTime startTime, + required DateTime endTime, + String? calendarId, + }) async { + if (_calendarApi == null || !_isCalendarConnected) { + return []; + } + + try { + final List allEvents = []; + + // 표시할 캘린더가 없는 경우 기본 캘린더만 조회 + final calendarsToFetch = + _visibleCalendarIds.isEmpty ? ['primary'] : _visibleCalendarIds; + + // 특정 캘린더만 조회하는 경우 + if (calendarId != null) { + final events = await _calendarApi!.events.list( + calendarId, + timeMin: startTime.toUtc(), + timeMax: endTime.toUtc(), + singleEvents: true, + orderBy: 'startTime', + ); + return events.items ?? []; + } + + // 모든 표시 대상 캘린더의 일정 조회 + for (final id in calendarsToFetch) { + final events = await _calendarApi!.events.list( + id, + timeMin: startTime.toUtc(), + timeMax: endTime.toUtc(), + singleEvents: true, + orderBy: 'startTime', + ); + + if (events.items != null) { + allEvents.addAll(events.items!); + } + } + + // 시간순으로 정렬 + allEvents.sort((a, b) { + final aStart = a.start?.dateTime; + final bStart = b.start?.dateTime; + + // 날짜만 있는 경우와 시간까지 있는 경우 처리 + if (aStart == null && bStart == null) { + final aDate = a.start?.date; + final bDate = b.start?.date; + if (aDate == null || bDate == null) return 0; + return aDate.compareTo(bDate); + } else if (aStart == null) { + return 1; // 날짜만 있는 일정은 뒤로 + } else if (bStart == null) { + return -1; // 날짜만 있는 일정은 뒤로 + } + + return aStart.compareTo(bStart); + }); + + return allEvents; + } catch (e) { + debugPrint('Get events error: $e'); + return []; + } + } + + /// 선택한 캘린더의 일정을 Heafit 캘린더로 동기화 + Future syncCalendarToHeafit({ + required List sourceCalendarIds, + required DateTime startTime, + required DateTime endTime, + }) async { + if (_calendarApi == null || + !_isCalendarConnected || + _heafitCalendarId == null) { + debugPrint( + '동기화 전제 조건 미충족: calendarApi=${_calendarApi != null}, isConnected=$_isCalendarConnected, heafitId=$_heafitCalendarId', + ); + return 0; + } + + try { + debugPrint('============ 캘린더 동기화 시작 ============'); + debugPrint( + '동기화 기간: ${startTime.toIso8601String()} ~ ${endTime.toIso8601String()}', + ); + + // Heafit 캘린더가 없으면 생성 + if (_heafitCalendarId == null) { + await _ensureHeafitCalendarExists(); + if (_heafitCalendarId == null) { + debugPrint('Heafit 캘린더 생성 실패'); + return 0; // 캘린더 생성 실패 + } + } + + // 동기화 설정 저장 + _syncSourceCalendarIds = List.from(sourceCalendarIds); + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + 'sync_source_calendar_ids', + _syncSourceCalendarIds, + ); + + int addedCount = 0; + int updatedCount = 0; + int deletedCount = 0; + int skippedCount = 0; + + // 작업 내역 기록을 위한 맵 + final Map> sourceCalendarEventIds = {}; + final Map> heafitEventIdMap = + {}; // 소스 ID -> Heafit ID 매핑 + + // 기존 Heafit 캘린더의 이벤트 로드 + debugPrint('기존 Heafit 캘린더 이벤트 로드 중...'); + final existingEvents = await _calendarApi!.events.list( + _heafitCalendarId!, + timeMin: startTime.toUtc(), + timeMax: endTime.toUtc(), + singleEvents: true, + maxResults: 2500, // 최대 이벤트 수 지정 + ); + + final existingHeafitEvents = existingEvents.items ?? []; + debugPrint('Heafit 캘린더에서 ${existingHeafitEvents.length}개의 이벤트 로드됨'); + + // 소스 ID별 Heafit 이벤트 ID 매핑 구성 + for (final event in existingHeafitEvents) { + if (event.description == null || event.id == null) continue; + + // 소스 캘린더 ID 추출 + final calendarIdRegex = RegExp(r'소스 캘린더: (.*?)\n'); + final calendarIdMatch = calendarIdRegex.firstMatch(event.description!); + + // 소스 이벤트 ID 추출 + final sourceIdRegex = RegExp(r'소스 ID: (.*?)\n'); + final sourceIdMatch = sourceIdRegex.firstMatch(event.description!); + + if (calendarIdMatch != null && + sourceIdMatch != null && + calendarIdMatch.groupCount >= 1 && + sourceIdMatch.groupCount >= 1) { + final calendarId = calendarIdMatch.group(1); + final sourceId = sourceIdMatch.group(1); + + if (calendarId != null && sourceId != null) { + if (heafitEventIdMap[calendarId] == null) { + heafitEventIdMap[calendarId] = {}; + } + heafitEventIdMap[calendarId]![sourceId] = event.id!; + } + } + } + + // 소스 캘린더별 처리 + for (final calendarId in sourceCalendarIds) { + debugPrint('$calendarId 캘린더 동기화 중...'); + sourceCalendarEventIds[calendarId] = {}; + + // 소스 캘린더에서 이벤트 가져오기 + final sourceEvents = await _calendarApi!.events.list( + calendarId, + timeMin: startTime.toUtc(), + timeMax: endTime.toUtc(), + singleEvents: true, + maxResults: 2500, + ); + + final sourceEventsList = sourceEvents.items ?? []; + debugPrint('소스 캘린더 $calendarId에서 ${sourceEventsList.length}개의 이벤트 로드됨'); + + // 이 캘린더로부터 동기화된 기존 이벤트 찾기 + final syncedFromThisCalendar = + existingHeafitEvents.where((event) { + return event.description != null && + event.description!.contains('소스 캘린더: $calendarId'); + }).toList(); + + debugPrint('이미 동기화된 이벤트 수: ${syncedFromThisCalendar.length}'); + + // 각 소스 이벤트의 ID 저장 (삭제 확인용) + for (final sourceEvent in sourceEventsList) { + if (sourceEvent.id != null) { + sourceCalendarEventIds[calendarId]!.add(sourceEvent.id!); + } + } + + // 1. 이벤트 추가 또는 업데이트 + for (final sourceEvent in sourceEventsList) { + if (sourceEvent.summary == null) { + skippedCount++; + continue; // 제목 없는 이벤트는 건너뜀 + } + + final sourceEventId = sourceEvent.id; + if (sourceEventId == null) { + skippedCount++; + continue; + } + + // 소스 ID에 해당하는 Heafit 이벤트 ID 찾기 + String? heafitEventId; + if (heafitEventIdMap.containsKey(calendarId) && + heafitEventIdMap[calendarId]!.containsKey(sourceEventId)) { + heafitEventId = heafitEventIdMap[calendarId]![sourceEventId]; + + // 업데이트 로직 + if (heafitEventId != null) { + try { + final newEvent = calendar.Event(); + newEvent.summary = sourceEvent.summary; + + // 소스 캘린더 정보 추가 + final sourceCal = userCalendars.firstWhere( + (cal) => cal.id == calendarId, + orElse: () => calendar.CalendarListEntry(summary: '기타 캘린더'), + ); + + final sourceCalName = sourceCal.summary ?? '기타 캘린더'; + // 추적을 위한 메타데이터 포함 + newEvent.description = + '${sourceEvent.description ?? ''}\n\n' + '[${sourceCalName}에서 동기화된 일정]\n' + '소스 캘린더: $calendarId\n' + '소스 ID: $sourceEventId\n' + '동기화 시간: ${DateTime.now()}'; + + // 시작 및 종료 시간 복사 + if (sourceEvent.start != null) { + newEvent.start = sourceEvent.start; + } + + if (sourceEvent.end != null) { + newEvent.end = sourceEvent.end; + } + + // 위치 정보 복사 + if (sourceEvent.location != null) { + newEvent.location = sourceEvent.location; + } + + // 반복 일정 정보 복사 + if (sourceEvent.recurrence != null) { + newEvent.recurrence = sourceEvent.recurrence; + } + + // 기타 추가 정보 복사 + if (sourceEvent.colorId != null) { + newEvent.colorId = sourceEvent.colorId; + } + + // Heafit 캘린더에 업데이트 + debugPrint('이벤트 업데이트: ${newEvent.summary}'); + await _calendarApi!.events.update( + newEvent, + _heafitCalendarId!, + heafitEventId, + ); + updatedCount++; + } catch (e) { + debugPrint('이벤트 업데이트 실패: $e'); + skippedCount++; + } + continue; + } + } + + // 새 이벤트 생성 및 추가 + try { + final newEvent = calendar.Event(); + newEvent.summary = sourceEvent.summary; + + // 소스 캘린더 정보 추가 + final sourceCal = userCalendars.firstWhere( + (cal) => cal.id == calendarId, + orElse: () => calendar.CalendarListEntry(summary: '기타 캘린더'), + ); + + final sourceCalName = sourceCal.summary ?? '기타 캘린더'; + // 추적을 위한 메타데이터 포함 + newEvent.description = + '${sourceEvent.description ?? ''}\n\n' + '[${sourceCalName}에서 동기화된 일정]\n' + '소스 캘린더: $calendarId\n' + '소스 ID: $sourceEventId\n' + '동기화 시간: ${DateTime.now()}'; + + // 시작 및 종료 시간 복사 + if (sourceEvent.start != null) { + newEvent.start = sourceEvent.start; + } + + if (sourceEvent.end != null) { + newEvent.end = sourceEvent.end; + } + + // 위치 정보 복사 + if (sourceEvent.location != null) { + newEvent.location = sourceEvent.location; + } + + // 반복 일정 정보 복사 + if (sourceEvent.recurrence != null) { + newEvent.recurrence = sourceEvent.recurrence; + } + + // 기타 추가 정보 복사 + if (sourceEvent.colorId != null) { + newEvent.colorId = sourceEvent.colorId; + } + + // Heafit 캘린더에 추가 + debugPrint('새 이벤트 추가: ${newEvent.summary}'); + final createdEvent = await _calendarApi!.events.insert( + newEvent, + _heafitCalendarId!, + ); + + // 매핑 저장 + if (createdEvent.id != null) { + if (heafitEventIdMap[calendarId] == null) { + heafitEventIdMap[calendarId] = {}; + } + heafitEventIdMap[calendarId]![sourceEventId] = createdEvent.id!; + } + + addedCount++; + } catch (e) { + debugPrint('새 이벤트 추가 실패: $e'); + skippedCount++; + } + } + + // 2. 소스 캘린더에서 삭제된 이벤트 찾아 제거 + for (final existingEvent in syncedFromThisCalendar) { + if (existingEvent.id == null || existingEvent.description == null) + continue; + + // 소스 ID 추출 + bool shouldDelete = false; + final sourceIdRegex = RegExp(r'소스 ID: (.*?)\n'); + final sourceIdMatch = sourceIdRegex.firstMatch( + existingEvent.description!, + ); + + if (sourceIdMatch != null && sourceIdMatch.groupCount >= 1) { + final sourceEventId = sourceIdMatch.group(1); + if (sourceEventId != null && + !sourceCalendarEventIds[calendarId]!.contains(sourceEventId)) { + shouldDelete = true; + debugPrint('소스 캘린더에서 삭제된 이벤트 감지: $sourceEventId'); + } + } + + if (shouldDelete) { + try { + debugPrint('이벤트 삭제: ${existingEvent.summary}'); + await _calendarApi!.events.delete( + _heafitCalendarId!, + existingEvent.id!, + ); + deletedCount++; + } catch (e) { + debugPrint('이벤트 삭제 실패: $e'); + } + } + } + } + + final totalProcessed = addedCount + updatedCount + deletedCount; + debugPrint( + '동기화 완료: $addedCount개 추가, $updatedCount개 업데이트, $deletedCount개 삭제, $skippedCount개 건너뜀, 총 $totalProcessed개 처리', + ); + debugPrint('============ 캘린더 동기화 종료 ============'); + return totalProcessed; + } catch (e) { + debugPrint('캘린더 동기화 오류: $e'); + return 0; + } + } +} + +/// 인증된 HTTP 클라이언트 +class AuthClient extends BaseClient { + final String clientId; + final String clientSecret; + final AccessCredentials credentials; + final Client _client = Client(); + + AuthClient(this.clientId, this.clientSecret, this.credentials); + + @override + Future send(BaseRequest request) { + request.headers['Authorization'] = 'Bearer ${credentials.accessToken.data}'; + return _client.send(request); + } + + @override + void close() { + _client.close(); + } +} diff --git a/pubspec.lock b/pubspec.lock index d4f3891..bfeeb46 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -198,14 +198,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" dart_style: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a7b9216..9a7e929 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,10 +31,6 @@ dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 - # 상태 관리 provider: ^6.1.1 get: ^4.6.6