From 50249a5d7db3f0ca8e671f1393300672fb11304a Mon Sep 17 00:00:00 2001 From: ya_yo0 Date: Mon, 15 Dec 2025 10:15:01 +0900 Subject: [PATCH 1/6] work history --- lib/core/services/notification_service.dart | 345 ++++++++++++++++-- lib/main.dart | 18 +- .../app/bloc/schedule/schedule_bloc.dart | 71 +++- 3 files changed, 396 insertions(+), 38 deletions(-) diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart index be8f0449..998b6855 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -1,16 +1,34 @@ import 'dart:async'; import 'dart:io' show Platform; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'dart:convert'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/js_interop_service.dart'; +import 'package:on_time_front/core/services/navigation_service.dart'; import 'package:on_time_front/data/data_sources/notification_remote_data_source.dart'; import 'package:on_time_front/data/models/fcm_token_register_request_model.dart'; @pragma('vm:entry-point') -Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async {} +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + debugPrint('═══════════════════════════════════════════════════════'); + debugPrint('[FCM Background Handler] 메시지 수신: ${message.messageId}'); + debugPrint('[FCM Background Handler] 데이터: ${message.data}'); + debugPrint( + '[FCM Background Handler] 알림: ${message.notification?.title} - ${message.notification?.body}'); + debugPrint('═══════════════════════════════════════════════════════'); + + try { + await Firebase.initializeApp(); + } catch (e) { + debugPrint('[FCM Background Handler] Firebase 초기화 오류: $e'); + } + await NotificationService.instance.setupFlutterNotifications(); + await NotificationService.instance.showNotification(message); +} class NotificationService { NotificationService._(); @@ -21,11 +39,33 @@ class NotificationService { bool _isFlutterLocalNotificationsInitialized = false; Future initialize() async { - FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + debugPrint('[FCM] NotificationService 초기화 시작'); + + try { + FirebaseMessaging.onBackgroundMessage( + _firebaseMessagingBackgroundHandler); + debugPrint('[FCM] Background message handler 등록 완료'); + } catch (e) { + debugPrint('[FCM] Background message handler 등록 실패: $e'); + } await _requestPermission(); - // Setup message handlers + await setupFlutterNotifications(); await _setupMessageHandlers(); + + await requestNotificationToken(); + + // iOS에서 포그라운드 알림 표시를 위한 설정 + if (Platform.isIOS) { + await _messaging.setForegroundNotificationPresentationOptions( + alert: true, + badge: true, + sound: true, + ); + debugPrint('[FCM] iOS 포그라운드 알림 표시 옵션 설정 완료'); + } + + debugPrint('[FCM] NotificationService 초기화 완료'); } Future checkNotificationPermission() async { @@ -36,7 +76,7 @@ class NotificationService { Future _requestPermission() async { if (kIsWeb) { final permission = await JsInteropService.requestNotificationPermission(); - print('Permission status: $permission'); + debugPrint('[FCM] Web Permission status: $permission'); } else { final settings = await _messaging.requestPermission( alert: true, @@ -48,27 +88,48 @@ class NotificationService { criticalAlert: false, ); - print('Permission status: ${settings.authorizationStatus}'); + debugPrint('[FCM] Permission status: ${settings.authorizationStatus}'); + debugPrint( + '[FCM] Alert: ${settings.alert}, Badge: ${settings.badge}, Sound: ${settings.sound}'); } } Future requestNotificationToken() async { - if (!kIsWeb) { - if (Platform.isIOS) { - final APNSToken = await _messaging.getAPNSToken(); - print('APNs Token: $APNSToken'); + try { + if (!kIsWeb) { + if (Platform.isIOS) { + final APNSToken = await _messaging.getAPNSToken(); + debugPrint('[FCM] APNs Token: $APNSToken'); + } } - } - // Get FCM token - final token = await _messaging.getToken(); - - print('FCM Token: $token'); - if (token != null) { - // Register FCM token with your server - print('Registering FCM token: $token'); - getIt.get().fcmTokenRegister( - FcmTokenRegisterRequestModel(firebaseToken: token), - ); + + final token = await _messaging.getToken(); + debugPrint('═══════════════════════════════════════════════════════'); + debugPrint('[FCM] FCM Token 획득: $token'); + debugPrint('═══════════════════════════════════════════════════════'); + + if (token != null) { + debugPrint('[FCM] FCM Token 서버 등록 시작'); + try { + await getIt.get().fcmTokenRegister( + FcmTokenRegisterRequestModel(firebaseToken: token), + ); + debugPrint('[FCM] FCM Token 서버 등록 완료'); + } catch (e) { + debugPrint('[FCM] FCM Token 서버 등록 실패: $e'); + } + } else { + debugPrint('[FCM] FCM Token이 null입니다'); + } + + _messaging.onTokenRefresh.listen((newToken) { + debugPrint('[FCM] Token 갱신됨: $newToken'); + getIt.get().fcmTokenRegister( + FcmTokenRegisterRequestModel(firebaseToken: newToken), + ); + }); + } catch (e) { + debugPrint('[FCM] Token 요청 오류: $e'); } } @@ -104,22 +165,123 @@ class NotificationService { // flutter notification setup await _localNotifications.initialize( initializationSettings, - onDidReceiveNotificationResponse: (details) {}, + onDidReceiveNotificationResponse: (details) { + _handleLocalNotificationTap(details.payload); + }, ); _isFlutterLocalNotificationsInitialized = true; } Future showNotification(RemoteMessage message) async { - RemoteNotification? notification = message.notification; - AndroidNotification? android = message.notification?.android; - if (notification != null && android != null) { + debugPrint('═══════════════════════════════════════════════════════'); + debugPrint('[FCM Show Notification] 메시지 수신 시작'); + debugPrint('[FCM Show Notification] 메시지 ID: ${message.messageId}'); + debugPrint('[FCM Show Notification] 데이터: ${message.data}'); + debugPrint( + '[FCM Show Notification] 데이터 키 목록: ${message.data.keys.toList()}'); + debugPrint( + '[FCM Show Notification] 알림 객체: ${message.notification?.toString()}'); + debugPrint('[FCM Show Notification] 알림 제목: ${message.notification?.title}'); + debugPrint('[FCM Show Notification] 알림 본문: ${message.notification?.body}'); + debugPrint('[FCM Show Notification] 메시지 전체: ${message.toString()}'); + debugPrint('═══════════════════════════════════════════════════════'); + + try { + await setupFlutterNotifications(); + } catch (e) { + debugPrint('[FCM Show Notification] setupFlutterNotifications 오류: $e'); + return; + } + + final notification = message.notification; + final String? title = + notification?.title ?? message.data['title'] ?? message.data['Title']; + final String? body = notification?.body ?? + message.data['content'] ?? + message.data['body'] ?? + message.data['Content'] ?? + message.data['Body']; + + debugPrint('[FCM Show Notification] 파싱된 제목: $title'); + debugPrint('[FCM Show Notification] 파싱된 본문: $body'); + debugPrint('[FCM Show Notification] 최종 제목: $title, 본문: $body'); + + if (title == null && body == null) { + debugPrint('[FCM Show Notification] 제목과 본문이 모두 null이어서 알림을 표시하지 않습니다'); + debugPrint('[FCM Show Notification] 사용 가능한 데이터 키: ${message.data.keys}'); + return; + } + + try { + final notificationId = ((title ?? '') + + (body ?? '') + + DateTime.now().millisecondsSinceEpoch.toString()) + .hashCode; + debugPrint('[FCM Show Notification] 알림 ID: $notificationId'); + debugPrint('[FCM Show Notification] 로컬 알림 표시 시작'); + + await _localNotifications.show( + notificationId, + title ?? '알림', + body ?? '', + NotificationDetails( + android: const AndroidNotificationDetails( + 'high_importance_channel', + 'High Importance Notifications', + channelDescription: + 'This channel is used for important notifications.', + importance: Importance.high, + priority: Priority.high, + icon: '@mipmap/ic_launcher', + playSound: true, + enableVibration: true, + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + ), + payload: jsonEncode(message.data), + ); + + debugPrint('[FCM Show Notification] 로컬 알림 표시 완료'); + } catch (e, stackTrace) { + debugPrint('[FCM Show Notification] 로컬 알림 표시 실패: $e'); + debugPrint('[FCM Show Notification] 스택 트레이스: $stackTrace'); + } + } + + Future showLocalNotification({ + required String title, + required String body, + Map? payload, + }) async { + debugPrint('═══════════════════════════════════════════════════════'); + debugPrint('[FCM Local Notification] 수동 표시 시작'); + debugPrint('[FCM Local Notification] 제목: $title'); + debugPrint('[FCM Local Notification] 본문: $body'); + debugPrint('[FCM Local Notification] 페이로드: $payload'); + debugPrint('═══════════════════════════════════════════════════════'); + + try { + await setupFlutterNotifications(); + } catch (e) { + debugPrint('[FCM Local Notification] setupFlutterNotifications 오류: $e'); + return; + } + + try { + final notificationId = + (title + body + DateTime.now().millisecondsSinceEpoch.toString()) + .hashCode; await _localNotifications.show( - notification.hashCode, - notification.title, - notification.body, + notificationId, + title, + body, NotificationDetails( - android: AndroidNotificationDetails( + android: const AndroidNotificationDetails( 'high_importance_channel', 'High Importance Notifications', channelDescription: @@ -127,6 +289,8 @@ class NotificationService { importance: Importance.high, priority: Priority.high, icon: '@mipmap/ic_launcher', + playSound: true, + enableVibration: true, ), iOS: const DarwinNotificationDetails( presentAlert: true, @@ -134,31 +298,140 @@ class NotificationService { presentSound: true, ), ), - payload: message.data.toString(), + payload: payload != null ? jsonEncode(payload) : null, ); + debugPrint('[FCM Local Notification] 표시 완료: $notificationId'); + } catch (e, stackTrace) { + debugPrint('[FCM Local Notification] 표시 실패: $e'); + debugPrint('[FCM Local Notification] 스택 트레이스: $stackTrace'); } } Future _setupMessageHandlers() async { + debugPrint('[FCM] Message handlers 설정 시작'); + //foreground message - FirebaseMessaging.onMessage.listen((message) { - print('Received message: ${message.notification?.body.toString()}'); - showNotification(message); + FirebaseMessaging.onMessage.listen( + (message) { + debugPrint('═══════════════════════════════════════════════════════'); + debugPrint('[FCM Foreground] 앱이 포그라운드에 있을 때 메시지 수신'); + debugPrint('[FCM Foreground] 메시지 ID: ${message.messageId}'); + debugPrint('[FCM Foreground] 데이터: ${message.data}'); + debugPrint( + '[FCM Foreground] 알림: ${message.notification?.title} - ${message.notification?.body}'); + debugPrint('[FCM Foreground] 메시지 전체: ${message.toString()}'); + debugPrint('═══════════════════════════════════════════════════════'); + + try { + showNotification(message); + } catch (e, stackTrace) { + debugPrint('[FCM Foreground] 알림 표시 오류: $e'); + debugPrint('[FCM Foreground] 스택 트레이스: $stackTrace'); + } + }, + onError: (error) { + debugPrint('[FCM Foreground] 리스너 오류: $error'); + }, + cancelOnError: false, + ); + debugPrint('[FCM] Foreground message handler 등록 완료'); + + // 리스너가 제대로 등록되었는지 확인 + _messaging.getInitialMessage().then((message) { + debugPrint('[FCM] Initial message 체크 완료'); + }).catchError((error) { + debugPrint('[FCM] Initial message 체크 오류: $error'); }); // background message - FirebaseMessaging.onMessageOpenedApp.listen(_handleBackgroundMessage); + FirebaseMessaging.onMessageOpenedApp.listen((message) { + debugPrint('═══════════════════════════════════════════════════════'); + debugPrint('[FCM Opened App] 백그라운드에서 알림 탭으로 앱 열림'); + debugPrint('[FCM Opened App] 메시지 ID: ${message.messageId}'); + debugPrint('[FCM Opened App] 데이터: ${message.data}'); + debugPrint('═══════════════════════════════════════════════════════'); + _handleBackgroundMessage(message); + }); + debugPrint('[FCM] Background message handler 등록 완료'); // opened app final initialMessage = await _messaging.getInitialMessage(); if (initialMessage != null) { + debugPrint('═══════════════════════════════════════════════════════'); + debugPrint('[FCM Initial Message] 종료 상태에서 알림 탭으로 앱 열림'); + debugPrint('[FCM Initial Message] 메시지 ID: ${initialMessage.messageId}'); + debugPrint('[FCM Initial Message] 데이터: ${initialMessage.data}'); + debugPrint('═══════════════════════════════════════════════════════'); _handleBackgroundMessage(initialMessage); + } else { + debugPrint('[FCM] Initial message 없음 (정상 시작)'); + } + + debugPrint('[FCM] Message handlers 설정 완료'); + } + + void _handleLocalNotificationTap(String? payload) { + debugPrint('[FCM Local Notification Tap] 알림 탭됨'); + debugPrint('[FCM Local Notification Tap] 페이로드: $payload'); + if (payload == null) { + debugPrint('[FCM Local Notification Tap] 페이로드가 null입니다'); + return; + } + try { + final data = jsonDecode(payload) as Map; + final type = data['type'] as String?; + final scheduleId = data['scheduleId'] as String?; + debugPrint( + '[FCM Local Notification Tap] 데이터: $data, 타입: $type, scheduleId: $scheduleId'); + + // 스케줄 관련 알림인 경우 + if (type != null && + (type.startsWith('schedule_') || + type.startsWith('preparation_')) || + scheduleId != null) { + debugPrint('[FCM Local Notification Tap] 스케줄 관련 알림으로 화면 이동'); + + // 타입에 따라 다르게 처리 + // schedule_5min_before, schedule_start, schedule_step, preparation_step 등 + // 모두 스케줄 시작/진행 화면으로 이동 (이미 시작된 경우도 같은 화면에서 처리) + getIt.get().push('/scheduleStart'); + } else if (type == 'chat') { + debugPrint('[FCM Local Notification Tap] 채팅 화면으로 이동'); + // open chat screen + } else { + debugPrint('[FCM Local Notification Tap] 알 수 없는 타입: $type'); + } + } catch (e) { + debugPrint('[FCM Local Notification Tap] 페이로드 파싱 오류: $e'); } } - void _handleBackgroundMessage(RemoteMessage message) { - if (message.data['type'] == 'chat') { + Future _handleBackgroundMessage(RemoteMessage message) async { + debugPrint('[FCM Handle Background] 백그라운드 메시지 처리 시작'); + debugPrint('[FCM Handle Background] 메시지 ID: ${message.messageId}'); + debugPrint('[FCM Handle Background] 데이터: ${message.data}'); + + final type = message.data['type'] as String?; + final scheduleId = message.data['scheduleId'] as String?; + debugPrint('[FCM Handle Background] 타입: $type, scheduleId: $scheduleId'); + + // 스케줄 관련 알림인 경우 (5분전, 시작, 단계별 모두 포함) + if (type != null && + (type.startsWith('schedule_') || type.startsWith('preparation_')) || + scheduleId != null) { + debugPrint('[FCM Handle Background] 스케줄 관련 알림으로 화면 이동'); + debugPrint('[FCM Handle Background] 타입 상세: $type - 스케줄 시작/진행 화면으로 이동'); + + // 타입에 관계없이 모두 스케줄 시작/진행 화면으로 이동 + // schedule_5min_before: 시작 전 알림 → 시작 화면으로 + // schedule_start: 시작 알림 → 시작 화면으로 + // schedule_step / preparation_step: 단계별 알림 → 이미 시작된 경우 진행 화면으로 (같은 /scheduleStart 경로) + getIt.get().push('/scheduleStart'); + } else if (type == 'chat') { + debugPrint('[FCM Handle Background] 채팅 화면으로 이동'); // open chat screen + } else { + debugPrint('[FCM Handle Background] 알 수 없는 타입: $type'); } } } diff --git a/lib/main.dart b/lib/main.dart index 6da2b124..4513aae7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,12 @@ import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:on_time_front/core/constants/environment_variable.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/device_info_service/shared.dart'; +import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/firebase_options.dart'; import 'package:on_time_front/presentation/app/screens/app.dart'; @@ -18,8 +20,22 @@ void main() async { options: DefaultFirebaseOptions.currentPlatform, ); } else { - await Firebase.initializeApp(); + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + } + debugPrint('[FCM Main] Firebase 초기화 완료'); + + final permission = + await NotificationService.instance.checkNotificationPermission(); + debugPrint('[FCM Main] Notification Permission: $permission'); + + if (permission == AuthorizationStatus.authorized) { + await NotificationService.instance.initialize(); + } else { + debugPrint('[FCM Main] 알림 권한이 없어 NotificationService를 초기화하지 않습니다'); } + debugPrint(DeviceInfoService.isInStandaloneMode.toString()); runApp(App()); } diff --git a/lib/presentation/app/bloc/schedule/schedule_bloc.dart b/lib/presentation/app/bloc/schedule/schedule_bloc.dart index 6ad49815..f06ccbb7 100644 --- a/lib/presentation/app/bloc/schedule/schedule_bloc.dart +++ b/lib/presentation/app/bloc/schedule/schedule_bloc.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/services/navigation_service.dart'; +import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; import 'package:on_time_front/domain/use-cases/save_timed_preparation_use_case.dart'; @@ -32,6 +33,7 @@ class ScheduleBloc extends Bloc { Timer? _scheduleStartTimer; String? _currentScheduleId; Timer? _preparationTimer; + final Map> _notifiedStepIdsByScheduleId = {}; Future _onSubscriptionRequested( ScheduleSubscriptionRequested event, Emitter emit) async { @@ -56,15 +58,18 @@ class ScheduleBloc extends Bloc { event.upcomingSchedule!.scheduleTime.isBefore(DateTime.now())) { emit(const ScheduleState.notExists()); _currentScheduleId = null; + _notifiedStepIdsByScheduleId.clear(); } else if (_isPreparationOnGoing(event.upcomingSchedule!)) { emit(ScheduleState.ongoing(event.upcomingSchedule!)); debugPrint( 'ongoingSchedule: ${event.upcomingSchedule}, currentStep: ${event.upcomingSchedule!.preparation.currentStep}'); + _initializeNotificationTracking(event.upcomingSchedule!); _startPreparationTimer(); } else { emit(ScheduleState.upcoming(event.upcomingSchedule!)); debugPrint('upcomingSchedule: ${event.upcomingSchedule}'); _currentScheduleId = event.upcomingSchedule!.id; + _initializeNotificationTracking(event.upcomingSchedule!); _startScheduleTimer(event.upcomingSchedule!); } } @@ -76,6 +81,7 @@ class ScheduleBloc extends Bloc { // Mark the schedule as started by updating the state debugPrint('scheddle started: ${state.schedule}'); emit(ScheduleState.started(state.schedule!)); + _initializeNotificationTracking(state.schedule!); _navigationService.push('/scheduleStart'); _startPreparationTimer(); } @@ -91,6 +97,8 @@ class ScheduleBloc extends Bloc { ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( state.schedule!, updatedPreparation); + _checkAndNotifyStepChange(state.schedule!, newSchedule); + emit(state.copyWith(schedule: newSchedule)); } @@ -144,5 +152,66 @@ class ScheduleBloc extends Bloc { schedule.scheduleTime.isAfter(DateTime.now()); } - // Removed unused helper since we now split in the event + void _initializeNotificationTracking(ScheduleWithPreparationEntity schedule) { + final scheduleId = schedule.id; + if (!_notifiedStepIdsByScheduleId.containsKey(scheduleId)) { + _notifiedStepIdsByScheduleId[scheduleId] = {}; + } + } + + void _checkAndNotifyStepChange( + ScheduleWithPreparationEntity oldSchedule, + ScheduleWithPreparationEntity newSchedule, + ) { + if (newSchedule.preparation.isAllStepsDone) { + return; + } + + final scheduleId = newSchedule.id; + final oldCurrentStep = oldSchedule.preparation.currentStep; + final newCurrentStep = newSchedule.preparation.currentStep; + + if (oldCurrentStep?.id == newCurrentStep?.id || newCurrentStep == null) { + return; + } + + final lastStep = newSchedule.preparation.preparationStepList.isNotEmpty + ? newSchedule.preparation.preparationStepList.last + : null; + if (lastStep != null && newCurrentStep.id == lastStep.id) { + return; + } + + final firstStep = newSchedule.preparation.preparationStepList.isNotEmpty + ? newSchedule.preparation.preparationStepList.first + : null; + if (firstStep != null && newCurrentStep.id == firstStep.id) { + return; + } + + final notifiedStepIds = _notifiedStepIdsByScheduleId[scheduleId] ?? {}; + if (notifiedStepIds.contains(newCurrentStep.id)) { + return; + } + + final title = + '[${newSchedule.scheduleName}] ${newCurrentStep.preparationName}'; + const body = '이어서 준비하세요.'; + + NotificationService.instance.showLocalNotification( + title: title, + body: body, + payload: { + 'type': 'preparation_step', + 'scheduleId': scheduleId, + 'stepId': newCurrentStep.id, + }, + ); + + notifiedStepIds.add(newCurrentStep.id); + _notifiedStepIdsByScheduleId[scheduleId] = notifiedStepIds; + + debugPrint( + '[ScheduleBloc] 단계 변경 알림 표시: $title, stepId: ${newCurrentStep.id}'); + } } From a4e3fa4cad55899c698d3538213f9722732cbf40 Mon Sep 17 00:00:00 2001 From: ya_yo0 Date: Mon, 12 Jan 2026 10:06:07 +0900 Subject: [PATCH 2/6] Add Implement Schedule Start Notifications with Tap Navigation --- lib/core/services/notification_service.dart | 159 +++--------------- lib/main.dart | 12 +- .../app/bloc/schedule/schedule_bloc.dart | 7 - 3 files changed, 27 insertions(+), 151 deletions(-) diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart index 998b6855..59613088 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -14,12 +14,7 @@ import 'package:on_time_front/data/models/fcm_token_register_request_model.dart' @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { - debugPrint('═══════════════════════════════════════════════════════'); - debugPrint('[FCM Background Handler] 메시지 수신: ${message.messageId}'); - debugPrint('[FCM Background Handler] 데이터: ${message.data}'); - debugPrint( - '[FCM Background Handler] 알림: ${message.notification?.title} - ${message.notification?.body}'); - debugPrint('═══════════════════════════════════════════════════════'); + debugPrint('[FCM Background Handler] 메시지 수신'); try { await Firebase.initializeApp(); @@ -39,8 +34,6 @@ class NotificationService { bool _isFlutterLocalNotificationsInitialized = false; Future initialize() async { - debugPrint('[FCM] NotificationService 초기화 시작'); - try { FirebaseMessaging.onBackgroundMessage( _firebaseMessagingBackgroundHandler); @@ -55,8 +48,7 @@ class NotificationService { await requestNotificationToken(); - // iOS에서 포그라운드 알림 표시를 위한 설정 - if (Platform.isIOS) { + if (!kIsWeb && Platform.isIOS) { await _messaging.setForegroundNotificationPresentationOptions( alert: true, badge: true, @@ -64,8 +56,6 @@ class NotificationService { ); debugPrint('[FCM] iOS 포그라운드 알림 표시 옵션 설정 완료'); } - - debugPrint('[FCM] NotificationService 초기화 완료'); } Future checkNotificationPermission() async { @@ -75,8 +65,7 @@ class NotificationService { Future _requestPermission() async { if (kIsWeb) { - final permission = await JsInteropService.requestNotificationPermission(); - debugPrint('[FCM] Web Permission status: $permission'); + await JsInteropService.requestNotificationPermission(); } else { final settings = await _messaging.requestPermission( alert: true, @@ -89,27 +78,15 @@ class NotificationService { ); debugPrint('[FCM] Permission status: ${settings.authorizationStatus}'); - debugPrint( - '[FCM] Alert: ${settings.alert}, Badge: ${settings.badge}, Sound: ${settings.sound}'); } } Future requestNotificationToken() async { try { - if (!kIsWeb) { - if (Platform.isIOS) { - final APNSToken = await _messaging.getAPNSToken(); - debugPrint('[FCM] APNs Token: $APNSToken'); - } - } - final token = await _messaging.getToken(); - debugPrint('═══════════════════════════════════════════════════════'); debugPrint('[FCM] FCM Token 획득: $token'); - debugPrint('═══════════════════════════════════════════════════════'); if (token != null) { - debugPrint('[FCM] FCM Token 서버 등록 시작'); try { await getIt.get().fcmTokenRegister( FcmTokenRegisterRequestModel(firebaseToken: token), @@ -118,8 +95,6 @@ class NotificationService { } catch (e) { debugPrint('[FCM] FCM Token 서버 등록 실패: $e'); } - } else { - debugPrint('[FCM] FCM Token이 null입니다'); } _messaging.onTokenRefresh.listen((newToken) { @@ -174,23 +149,10 @@ class NotificationService { } Future showNotification(RemoteMessage message) async { - debugPrint('═══════════════════════════════════════════════════════'); - debugPrint('[FCM Show Notification] 메시지 수신 시작'); - debugPrint('[FCM Show Notification] 메시지 ID: ${message.messageId}'); - debugPrint('[FCM Show Notification] 데이터: ${message.data}'); - debugPrint( - '[FCM Show Notification] 데이터 키 목록: ${message.data.keys.toList()}'); - debugPrint( - '[FCM Show Notification] 알림 객체: ${message.notification?.toString()}'); - debugPrint('[FCM Show Notification] 알림 제목: ${message.notification?.title}'); - debugPrint('[FCM Show Notification] 알림 본문: ${message.notification?.body}'); - debugPrint('[FCM Show Notification] 메시지 전체: ${message.toString()}'); - debugPrint('═══════════════════════════════════════════════════════'); - try { await setupFlutterNotifications(); } catch (e) { - debugPrint('[FCM Show Notification] setupFlutterNotifications 오류: $e'); + debugPrint('[FCM] setupFlutterNotifications 오류: $e'); return; } @@ -203,13 +165,7 @@ class NotificationService { message.data['Content'] ?? message.data['Body']; - debugPrint('[FCM Show Notification] 파싱된 제목: $title'); - debugPrint('[FCM Show Notification] 파싱된 본문: $body'); - debugPrint('[FCM Show Notification] 최종 제목: $title, 본문: $body'); - if (title == null && body == null) { - debugPrint('[FCM Show Notification] 제목과 본문이 모두 null이어서 알림을 표시하지 않습니다'); - debugPrint('[FCM Show Notification] 사용 가능한 데이터 키: ${message.data.keys}'); return; } @@ -218,8 +174,6 @@ class NotificationService { (body ?? '') + DateTime.now().millisecondsSinceEpoch.toString()) .hashCode; - debugPrint('[FCM Show Notification] 알림 ID: $notificationId'); - debugPrint('[FCM Show Notification] 로컬 알림 표시 시작'); await _localNotifications.show( notificationId, @@ -245,11 +199,9 @@ class NotificationService { ), payload: jsonEncode(message.data), ); - - debugPrint('[FCM Show Notification] 로컬 알림 표시 완료'); } catch (e, stackTrace) { - debugPrint('[FCM Show Notification] 로컬 알림 표시 실패: $e'); - debugPrint('[FCM Show Notification] 스택 트레이스: $stackTrace'); + debugPrint('[FCM] 로컬 알림 표시 실패: $e'); + debugPrint('[FCM] 스택 트레이스: $stackTrace'); } } @@ -258,17 +210,10 @@ class NotificationService { required String body, Map? payload, }) async { - debugPrint('═══════════════════════════════════════════════════════'); - debugPrint('[FCM Local Notification] 수동 표시 시작'); - debugPrint('[FCM Local Notification] 제목: $title'); - debugPrint('[FCM Local Notification] 본문: $body'); - debugPrint('[FCM Local Notification] 페이로드: $payload'); - debugPrint('═══════════════════════════════════════════════════════'); - try { await setupFlutterNotifications(); } catch (e) { - debugPrint('[FCM Local Notification] setupFlutterNotifications 오류: $e'); + debugPrint('[FCM] setupFlutterNotifications 오류: $e'); return; } @@ -300,28 +245,16 @@ class NotificationService { ), payload: payload != null ? jsonEncode(payload) : null, ); - debugPrint('[FCM Local Notification] 표시 완료: $notificationId'); } catch (e, stackTrace) { - debugPrint('[FCM Local Notification] 표시 실패: $e'); - debugPrint('[FCM Local Notification] 스택 트레이스: $stackTrace'); + debugPrint('[FCM] 로컬 알림 표시 실패: $e'); + debugPrint('[FCM] 스택 트레이스: $stackTrace'); } } Future _setupMessageHandlers() async { - debugPrint('[FCM] Message handlers 설정 시작'); - //foreground message FirebaseMessaging.onMessage.listen( (message) { - debugPrint('═══════════════════════════════════════════════════════'); - debugPrint('[FCM Foreground] 앱이 포그라운드에 있을 때 메시지 수신'); - debugPrint('[FCM Foreground] 메시지 ID: ${message.messageId}'); - debugPrint('[FCM Foreground] 데이터: ${message.data}'); - debugPrint( - '[FCM Foreground] 알림: ${message.notification?.title} - ${message.notification?.body}'); - debugPrint('[FCM Foreground] 메시지 전체: ${message.toString()}'); - debugPrint('═══════════════════════════════════════════════════════'); - try { showNotification(message); } catch (e, stackTrace) { @@ -336,20 +269,8 @@ class NotificationService { ); debugPrint('[FCM] Foreground message handler 등록 완료'); - // 리스너가 제대로 등록되었는지 확인 - _messaging.getInitialMessage().then((message) { - debugPrint('[FCM] Initial message 체크 완료'); - }).catchError((error) { - debugPrint('[FCM] Initial message 체크 오류: $error'); - }); - // background message FirebaseMessaging.onMessageOpenedApp.listen((message) { - debugPrint('═══════════════════════════════════════════════════════'); - debugPrint('[FCM Opened App] 백그라운드에서 알림 탭으로 앱 열림'); - debugPrint('[FCM Opened App] 메시지 ID: ${message.messageId}'); - debugPrint('[FCM Opened App] 데이터: ${message.data}'); - debugPrint('═══════════════════════════════════════════════════════'); _handleBackgroundMessage(message); }); debugPrint('[FCM] Background message handler 등록 완료'); @@ -357,81 +278,49 @@ class NotificationService { // opened app final initialMessage = await _messaging.getInitialMessage(); if (initialMessage != null) { - debugPrint('═══════════════════════════════════════════════════════'); - debugPrint('[FCM Initial Message] 종료 상태에서 알림 탭으로 앱 열림'); - debugPrint('[FCM Initial Message] 메시지 ID: ${initialMessage.messageId}'); - debugPrint('[FCM Initial Message] 데이터: ${initialMessage.data}'); - debugPrint('═══════════════════════════════════════════════════════'); _handleBackgroundMessage(initialMessage); - } else { - debugPrint('[FCM] Initial message 없음 (정상 시작)'); } - - debugPrint('[FCM] Message handlers 설정 완료'); } void _handleLocalNotificationTap(String? payload) { - debugPrint('[FCM Local Notification Tap] 알림 탭됨'); - debugPrint('[FCM Local Notification Tap] 페이로드: $payload'); + debugPrint('[FCM] 알림 탭'); if (payload == null) { - debugPrint('[FCM Local Notification Tap] 페이로드가 null입니다'); return; } try { final data = jsonDecode(payload) as Map; final type = data['type'] as String?; final scheduleId = data['scheduleId'] as String?; - debugPrint( - '[FCM Local Notification Tap] 데이터: $data, 타입: $type, scheduleId: $scheduleId'); + // final title = data['title'] as String?; - // 스케줄 관련 알림인 경우 if (type != null && (type.startsWith('schedule_') || type.startsWith('preparation_')) || scheduleId != null) { - debugPrint('[FCM Local Notification Tap] 스케줄 관련 알림으로 화면 이동'); - - // 타입에 따라 다르게 처리 - // schedule_5min_before, schedule_start, schedule_step, preparation_step 등 - // 모두 스케줄 시작/진행 화면으로 이동 (이미 시작된 경우도 같은 화면에서 처리) - getIt.get().push('/scheduleStart'); - } else if (type == 'chat') { - debugPrint('[FCM Local Notification Tap] 채팅 화면으로 이동'); - // open chat screen - } else { - debugPrint('[FCM Local Notification Tap] 알 수 없는 타입: $type'); - } + getIt.get().push('/alarmScreen'); + } + // else if (title != null && title.contains('약속')) { + // getIt.get().push('/alarmScreen'); + // } } catch (e) { - debugPrint('[FCM Local Notification Tap] 페이로드 파싱 오류: $e'); + debugPrint('[FCM] 페이로드 파싱 오류: $e'); } } Future _handleBackgroundMessage(RemoteMessage message) async { - debugPrint('[FCM Handle Background] 백그라운드 메시지 처리 시작'); - debugPrint('[FCM Handle Background] 메시지 ID: ${message.messageId}'); - debugPrint('[FCM Handle Background] 데이터: ${message.data}'); + debugPrint('[FCM] 백그라운드 메시지 처리'); final type = message.data['type'] as String?; final scheduleId = message.data['scheduleId'] as String?; - debugPrint('[FCM Handle Background] 타입: $type, scheduleId: $scheduleId'); + // final title = message.data['title'] as String?; - // 스케줄 관련 알림인 경우 (5분전, 시작, 단계별 모두 포함) if (type != null && (type.startsWith('schedule_') || type.startsWith('preparation_')) || scheduleId != null) { - debugPrint('[FCM Handle Background] 스케줄 관련 알림으로 화면 이동'); - debugPrint('[FCM Handle Background] 타입 상세: $type - 스케줄 시작/진행 화면으로 이동'); - - // 타입에 관계없이 모두 스케줄 시작/진행 화면으로 이동 - // schedule_5min_before: 시작 전 알림 → 시작 화면으로 - // schedule_start: 시작 알림 → 시작 화면으로 - // schedule_step / preparation_step: 단계별 알림 → 이미 시작된 경우 진행 화면으로 (같은 /scheduleStart 경로) - getIt.get().push('/scheduleStart'); - } else if (type == 'chat') { - debugPrint('[FCM Handle Background] 채팅 화면으로 이동'); - // open chat screen - } else { - debugPrint('[FCM Handle Background] 알 수 없는 타입: $type'); - } + getIt.get().push('/alarmScreen'); + } + // else if (title != null && title.contains('약속')) { + // getIt.get().push('/alarmScreen'); + // } } } diff --git a/lib/main.dart b/lib/main.dart index 4513aae7..94203b13 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,15 +15,9 @@ void main() async { await initializeDateFormatting(); configureDependencies(); debugPrint(EnvironmentVariable.restApiUrl); - if (kIsWeb) { - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); - } else { - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); - } + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); debugPrint('[FCM Main] Firebase 초기화 완료'); final permission = diff --git a/lib/presentation/app/bloc/schedule/schedule_bloc.dart b/lib/presentation/app/bloc/schedule/schedule_bloc.dart index edd30106..1cfaf396 100644 --- a/lib/presentation/app/bloc/schedule/schedule_bloc.dart +++ b/lib/presentation/app/bloc/schedule/schedule_bloc.dart @@ -193,13 +193,6 @@ class ScheduleBloc extends Bloc { return; } - final lastStep = newSchedule.preparation.preparationStepList.isNotEmpty - ? newSchedule.preparation.preparationStepList.last - : null; - if (lastStep != null && newCurrentStep.id == lastStep.id) { - return; - } - final firstStep = newSchedule.preparation.preparationStepList.isNotEmpty ? newSchedule.preparation.preparationStepList.first : null; From 9ec540646eef893f628fa37c1904a3812c9e3805 Mon Sep 17 00:00:00 2001 From: ya_yo0 Date: Mon, 19 Jan 2026 19:37:00 +0900 Subject: [PATCH 3/6] feat(notification): add fcm and local notification handling --- lib/core/services/notification_service.dart | 86 ++++++++++++++++++- lib/l10n/app_en.arb | 52 +++++++++++ lib/l10n/app_ko.arb | 24 +++++- lib/l10n/app_localizations.dart | 78 +++++++++++++++++ lib/l10n/app_localizations_en.dart | 45 ++++++++++ lib/l10n/app_localizations_ko.dart | 42 +++++++++ .../alarm_screen_bottom_section.dart | 2 +- .../components/alarm_screen_top_section.dart | 3 +- .../app/bloc/schedule/schedule_bloc.dart | 19 ++-- lib/presentation/shared/router/go_router.dart | 13 ++- 10 files changed, 340 insertions(+), 24 deletions(-) diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart index 59613088..731ddbfc 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:firebase_core/firebase_core.dart'; import 'dart:convert'; @@ -11,6 +12,8 @@ import 'package:on_time_front/core/services/js_interop_service.dart'; import 'package:on_time_front/core/services/navigation_service.dart'; import 'package:on_time_front/data/data_sources/notification_remote_data_source.dart'; import 'package:on_time_front/data/models/fcm_token_register_request_model.dart'; +import 'package:permission_handler/permission_handler.dart' + as permission_handler; @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { @@ -33,6 +36,19 @@ class NotificationService { final _localNotifications = FlutterLocalNotificationsPlugin(); bool _isFlutterLocalNotificationsInitialized = false; + String get _locale { + try { + final locale = ui.PlatformDispatcher.instance.locale; + return locale.languageCode; + } catch (e) { + return 'ko'; + } + } + + String _getLocalizedText(String ko, String en) { + return _locale == 'ko' ? ko : en; + } + Future initialize() async { try { FirebaseMessaging.onBackgroundMessage( @@ -63,6 +79,38 @@ class NotificationService { return settings.authorizationStatus; } + Future requestPermission() async { + if (kIsWeb) { + await JsInteropService.requestNotificationPermission(); + final settings = await _messaging.getNotificationSettings(); + return settings.authorizationStatus; + } else { + final settings = await _messaging.requestPermission( + alert: true, + badge: true, + sound: true, + provisional: false, + announcement: false, + carPlay: false, + criticalAlert: false, + ); + + debugPrint('[FCM] Permission status: ${settings.authorizationStatus}'); + return settings.authorizationStatus; + } + } + + Future openNotificationSettings() async { + try { + final opened = await permission_handler.openAppSettings(); + debugPrint('[FCM] 앱 설정 열기: $opened'); + return opened; + } catch (e) { + debugPrint('[FCM] 앱 설정 열기 실패: $e'); + return false; + } + } + Future _requestPermission() async { if (kIsWeb) { await JsInteropService.requestNotificationPermission(); @@ -251,6 +299,26 @@ class NotificationService { } } + Future showPreparationStepNotification({ + required String scheduleName, + required String preparationName, + required String scheduleId, + required String stepId, + }) async { + final title = '[$scheduleName] $preparationName'; + final body = _getLocalizedText('이어서 준비하세요.', 'Continue preparing'); + + await showLocalNotification( + title: title, + body: body, + payload: { + 'type': 'preparation_step', + 'scheduleId': scheduleId, + 'stepId': stepId, + }, + ); + } + Future _setupMessageHandlers() async { //foreground message FirebaseMessaging.onMessage.listen( @@ -293,12 +361,17 @@ class NotificationService { final scheduleId = data['scheduleId'] as String?; // final title = data['title'] as String?; - if (type != null && + if (type != null && type.contains('5min')) { + getIt.get().push( + '/scheduleStart', + extra: {'isFiveMinutesBefore': true}, + ); + } else if (type != null && (type.startsWith('schedule_') || type.startsWith('preparation_')) || scheduleId != null) { getIt.get().push('/alarmScreen'); - } + } // else if (title != null && title.contains('약속')) { // getIt.get().push('/alarmScreen'); // } @@ -314,11 +387,16 @@ class NotificationService { final scheduleId = message.data['scheduleId'] as String?; // final title = message.data['title'] as String?; - if (type != null && + if (type != null && type.contains('5min')) { + getIt.get().push( + '/scheduleStart', + extra: {'isFiveMinutesBefore': true}, + ); + } else if (type != null && (type.startsWith('schedule_') || type.startsWith('preparation_')) || scheduleId != null) { getIt.get().push('/alarmScreen'); - } + } // else if (title != null && title.contains('약속')) { // getIt.get().push('/alarmScreen'); // } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6eb1732a..35678f38 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -394,6 +394,58 @@ "sendFeedbackAndDelete": "Send feedback and delete", "@sendFeedbackAndDelete": { "description": "Button to send feedback and delete" + }, + "preparationStartsInFiveMinutes": "Preparation starts in 5 minutes.\nWould you like to start preparing early?", + "@preparationStartsInFiveMinutes": { + "description": "Message shown when receiving 5 minutes before notification" + }, + "startInFiveMinutes": "Start in 5 minutes", + "@startInFiveMinutes": { + "description": "Button text to start in 5 minutes" + }, + "continuePreparingNext": "Continue preparing", + "@continuePreparingNext": { + "description": "Notification body text for continuing preparation" + }, + "notificationAlreadyEnabled": "Notification Already Enabled", + "@notificationAlreadyEnabled": { + "description": "Dialog title when notification is already enabled" + }, + "notificationAlreadyEnabledDescription": "App notifications are currently active.", + "@notificationAlreadyEnabledDescription": { + "description": "Dialog content when notification is already enabled" + }, + "notificationPermissionRequired": "Notification Permission Required", + "@notificationPermissionRequired": { + "description": "Dialog title when requesting notification permission" + }, + "notificationPermissionRequiredDescription": "We'll send you notifications so you don't miss your appointments.\nWould you like to allow notifications?", + "@notificationPermissionRequiredDescription": { + "description": "Dialog content when requesting notification permission" + }, + "allow": "Allow", + "@allow": { + "description": "Button text to allow permission" + }, + "notificationPermissionGranted": "Notification Permission Granted", + "@notificationPermissionGranted": { + "description": "Dialog title when notification permission is granted" + }, + "notificationPermissionGrantedDescription": "Notifications have been successfully activated.", + "@notificationPermissionGrantedDescription": { + "description": "Dialog content when notification permission is granted" + }, + "openNotificationSettings": "Allow Notifications in Settings", + "@openNotificationSettings": { + "description": "Dialog title to open notification settings" + }, + "openNotificationSettingsDescription": "Notification permission was denied.\nPlease allow notifications in Settings.", + "@openNotificationSettingsDescription": { + "description": "Dialog content to open notification settings" + }, + "openSettings": "Open Settings", + "@openSettings": { + "description": "Button text to open app settings" } } \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 0a6f9fb6..a195018c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -120,5 +120,27 @@ "deleteFeedbackDescription": "더 나은 온타임이 될 수 있도록,\n어떤 점이 불편하셨는지\n알려주시면 감사하겠습니다.", "deleteFeedbackPlaceholder": "탈퇴하시는 이유를 알려주세요.", "keepUsingLong": "탈퇴하지 않고 계속 사용하기", - "sendFeedbackAndDelete": "의견 보내고 탈퇴하기" + "sendFeedbackAndDelete": "의견 보내고 탈퇴하기", + "preparationStartsInFiveMinutes": "5분 뒤에 준비가 시작돼요.\n미리 준비를 시작하시겠어요?", + "@preparationStartsInFiveMinutes": { + "description": "Message shown when receiving 5 minutes before notification" + }, + "startInFiveMinutes": "5분 뒤에 시작하기", + "@startInFiveMinutes": { + "description": "Button text to start in 5 minutes" + }, + "continuePreparingNext": "이어서 준비하세요", + "@continuePreparingNext": { + "description": "Notification body text for continuing preparation" + }, + "notificationAlreadyEnabled": "알림이 이미 허용됨", + "notificationAlreadyEnabledDescription": "현재 앱 알림이 활성화되어 있습니다.", + "notificationPermissionRequired": "알림 권한 필요", + "notificationPermissionRequiredDescription": "약속 시간을 놓치지 않도록 알림을 보내드립니다.\n알림을 허용하시겠습니까?", + "allow": "허용", + "notificationPermissionGranted": "알림 허용 완료", + "notificationPermissionGrantedDescription": "알림이 성공적으로 활성화되었습니다.", + "openNotificationSettings": "설정에서 알림 허용", + "openNotificationSettingsDescription": "알림 권한이 거부되었습니다.\n설정에서 직접 알림을 허용해주세요.", + "openSettings": "설정 열기" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6250160f..199f6aa4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -625,6 +625,84 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Send feedback and delete'** String get sendFeedbackAndDelete; + + /// Message shown when receiving 5 minutes before notification + /// + /// In en, this message translates to: + /// **'Preparation starts in 5 minutes.\nWould you like to start preparing early?'** + String get preparationStartsInFiveMinutes; + + /// Button text to start in 5 minutes + /// + /// In en, this message translates to: + /// **'Start in 5 minutes'** + String get startInFiveMinutes; + + /// Notification body text for continuing preparation + /// + /// In en, this message translates to: + /// **'Continue preparing'** + String get continuePreparingNext; + + /// Dialog title when notification is already enabled + /// + /// In en, this message translates to: + /// **'Notification Already Enabled'** + String get notificationAlreadyEnabled; + + /// Dialog content when notification is already enabled + /// + /// In en, this message translates to: + /// **'App notifications are currently active.'** + String get notificationAlreadyEnabledDescription; + + /// Dialog title when requesting notification permission + /// + /// In en, this message translates to: + /// **'Notification Permission Required'** + String get notificationPermissionRequired; + + /// Dialog content when requesting notification permission + /// + /// In en, this message translates to: + /// **'We\'ll send you notifications so you don\'t miss your appointments.\nWould you like to allow notifications?'** + String get notificationPermissionRequiredDescription; + + /// Button text to allow permission + /// + /// In en, this message translates to: + /// **'Allow'** + String get allow; + + /// Dialog title when notification permission is granted + /// + /// In en, this message translates to: + /// **'Notification Permission Granted'** + String get notificationPermissionGranted; + + /// Dialog content when notification permission is granted + /// + /// In en, this message translates to: + /// **'Notifications have been successfully activated.'** + String get notificationPermissionGrantedDescription; + + /// Dialog title to open notification settings + /// + /// In en, this message translates to: + /// **'Allow Notifications in Settings'** + String get openNotificationSettings; + + /// Dialog content to open notification settings + /// + /// In en, this message translates to: + /// **'Notification permission was denied.\nPlease allow notifications in Settings.'** + String get openNotificationSettingsDescription; + + /// Button text to open app settings + /// + /// In en, this message translates to: + /// **'Open Settings'** + String get openSettings; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7688ca92..b66c7b08 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -312,4 +312,49 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sendFeedbackAndDelete => 'Send feedback and delete'; + + @override + String get preparationStartsInFiveMinutes => + 'Preparation starts in 5 minutes.\nWould you like to start preparing early?'; + + @override + String get startInFiveMinutes => 'Start in 5 minutes'; + + @override + String get continuePreparingNext => 'Continue preparing'; + + @override + String get notificationAlreadyEnabled => 'Notification Already Enabled'; + + @override + String get notificationAlreadyEnabledDescription => + 'App notifications are currently active.'; + + @override + String get notificationPermissionRequired => + 'Notification Permission Required'; + + @override + String get notificationPermissionRequiredDescription => + 'We\'ll send you notifications so you don\'t miss your appointments.\nWould you like to allow notifications?'; + + @override + String get allow => 'Allow'; + + @override + String get notificationPermissionGranted => 'Notification Permission Granted'; + + @override + String get notificationPermissionGrantedDescription => + 'Notifications have been successfully activated.'; + + @override + String get openNotificationSettings => 'Allow Notifications in Settings'; + + @override + String get openNotificationSettingsDescription => + 'Notification permission was denied.\nPlease allow notifications in Settings.'; + + @override + String get openSettings => 'Open Settings'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index fb8e691c..f9c2a7ae 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -290,4 +290,46 @@ class AppLocalizationsKo extends AppLocalizations { @override String get sendFeedbackAndDelete => '의견 보내고 탈퇴하기'; + + @override + String get preparationStartsInFiveMinutes => + '5분 뒤에 준비가 시작돼요.\n미리 준비를 시작하시겠어요?'; + + @override + String get startInFiveMinutes => '5분 뒤에 시작하기'; + + @override + String get continuePreparingNext => '이어서 준비하세요'; + + @override + String get notificationAlreadyEnabled => '알림이 이미 허용됨'; + + @override + String get notificationAlreadyEnabledDescription => '현재 앱 알림이 활성화되어 있습니다.'; + + @override + String get notificationPermissionRequired => '알림 권한 필요'; + + @override + String get notificationPermissionRequiredDescription => + '약속 시간을 놓치지 않도록 알림을 보내드립니다.\n알림을 허용하시겠습니까?'; + + @override + String get allow => '허용'; + + @override + String get notificationPermissionGranted => '알림 허용 완료'; + + @override + String get notificationPermissionGrantedDescription => '알림이 성공적으로 활성화되었습니다.'; + + @override + String get openNotificationSettings => '설정에서 알림 허용'; + + @override + String get openNotificationSettingsDescription => + '알림 권한이 거부되었습니다.\n설정에서 직접 알림을 허용해주세요.'; + + @override + String get openSettings => '설정 열기'; } diff --git a/lib/presentation/alarm/components/alarm_screen_bottom_section.dart b/lib/presentation/alarm/components/alarm_screen_bottom_section.dart index 2909762e..5997b13f 100644 --- a/lib/presentation/alarm/components/alarm_screen_bottom_section.dart +++ b/lib/presentation/alarm/components/alarm_screen_bottom_section.dart @@ -95,7 +95,7 @@ class _EndPreparationButtonSection extends StatelessWidget { Widget build(BuildContext context) { return Container( color: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 10), + padding: const EdgeInsets.only(top: 16, bottom: 20), child: Center( child: ElevatedButton( onPressed: onEndPreparation, diff --git a/lib/presentation/alarm/components/alarm_screen_top_section.dart b/lib/presentation/alarm/components/alarm_screen_top_section.dart index c08d4202..d7a67479 100644 --- a/lib/presentation/alarm/components/alarm_screen_top_section.dart +++ b/lib/presentation/alarm/components/alarm_screen_top_section.dart @@ -27,7 +27,6 @@ class AlarmScreenTopSection extends StatelessWidget { isLate: isLate, beforeOutTime: beforeOutTime, ), - const SizedBox(height: 10), _AlarmGraphSection( preparationName: preparationName, preparationRemainingTime: preparationRemainingTime, @@ -50,7 +49,7 @@ class _BeforeOutTimeText extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(top: 45), + padding: const EdgeInsets.only(top: 75), child: Text( isLate ? '지각이에요!' : '${formatTime(beforeOutTime)} 뒤에 나가야 해요', style: const TextStyle( diff --git a/lib/presentation/app/bloc/schedule/schedule_bloc.dart b/lib/presentation/app/bloc/schedule/schedule_bloc.dart index 1cfaf396..471d084b 100644 --- a/lib/presentation/app/bloc/schedule/schedule_bloc.dart +++ b/lib/presentation/app/bloc/schedule/schedule_bloc.dart @@ -205,24 +205,17 @@ class ScheduleBloc extends Bloc { return; } - final title = - '[${newSchedule.scheduleName}] ${newCurrentStep.preparationName}'; - const body = '이어서 준비하세요.'; - - NotificationService.instance.showLocalNotification( - title: title, - body: body, - payload: { - 'type': 'preparation_step', - 'scheduleId': scheduleId, - 'stepId': newCurrentStep.id, - }, + NotificationService.instance.showPreparationStepNotification( + scheduleName: newSchedule.scheduleName, + preparationName: newCurrentStep.preparationName, + scheduleId: scheduleId, + stepId: newCurrentStep.id, ); notifiedStepIds.add(newCurrentStep.id); _notifiedStepIdsByScheduleId[scheduleId] = notifiedStepIds; debugPrint( - '[ScheduleBloc] 단계 변경 알림 표시: $title, stepId: ${newCurrentStep.id}'); + '[ScheduleBloc] 단계 변경 알림 표시: [${newSchedule.scheduleName}] ${newCurrentStep.preparationName}, stepId: ${newCurrentStep.id}'); } } diff --git a/lib/presentation/shared/router/go_router.dart b/lib/presentation/shared/router/go_router.dart index 72604b43..91b6476e 100644 --- a/lib/presentation/shared/router/go_router.dart +++ b/lib/presentation/shared/router/go_router.dart @@ -6,7 +6,6 @@ import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/navigation_service.dart'; import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/presentation/alarm/screens/alarm_screen.dart'; import 'package:on_time_front/presentation/alarm/screens/schedule_start_screen.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; @@ -102,7 +101,12 @@ GoRouter goRouterConfig(AuthBloc authBloc, ScheduleBloc scheduleBloc) { builder: (context, state) => PreparationSpareTimeEditScreen(), ), GoRoute(path: '/signIn', builder: (context, state) => SignInMainScreen()), - GoRoute(path: '/calendar', builder: (context, state) => CalendarScreen()), + GoRoute( + path: '/calendar', + builder: (context, state) => CalendarScreen( + initialDate: state.extra as DateTime?, + ), + ), GoRoute( path: '/scheduleCreate', builder: (context, state) => ScheduleCreateScreen()), @@ -122,7 +126,10 @@ GoRouter goRouterConfig(AuthBloc authBloc, ScheduleBloc scheduleBloc) { if (schedule == null) { return const SizedBox.shrink(); } - return const ScheduleStartScreen(); + final extra = state.extra as Map?; + final isFiveMinutesBefore = + extra?['isFiveMinutesBefore'] as bool? ?? false; + return ScheduleStartScreen(isFiveMinutesBefore: isFiveMinutesBefore); }, ), GoRoute( From 8e4ce04d236e3d649f2bc4375818e0426990ef3a Mon Sep 17 00:00:00 2001 From: ya_yo0 Date: Mon, 19 Jan 2026 19:37:35 +0900 Subject: [PATCH 4/6] feat(preparation): modify preparation process logic and ui --- .../alarm/screens/schedule_start_screen.dart | 204 +++++++++++------- .../early_late/screens/early_late_screen.dart | 58 ++--- .../components/schedule_multi_page_form.dart | 2 +- .../cubit/schedule_form_spare_time_cubit.dart | 10 +- .../preparation_form_list_field.dart | 8 +- .../components/preparation_time_input.dart | 6 +- .../screens/preparation_edit_form.dart | 1 + .../screens/schedule_create_screen.dart | 4 +- 8 files changed, 180 insertions(+), 113 deletions(-) diff --git a/lib/presentation/alarm/screens/schedule_start_screen.dart b/lib/presentation/alarm/screens/schedule_start_screen.dart index 832311f7..a6733ef7 100644 --- a/lib/presentation/alarm/screens/schedule_start_screen.dart +++ b/lib/presentation/alarm/screens/schedule_start_screen.dart @@ -7,10 +7,14 @@ import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart' import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/shared/components/custom_alert_dialog.dart'; import 'package:on_time_front/presentation/shared/components/modal_button.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; class ScheduleStartScreen extends StatefulWidget { + final bool isFiveMinutesBefore; + const ScheduleStartScreen({ super.key, + this.isFiveMinutesBefore = false, }); @override @@ -28,91 +32,147 @@ class _ScheduleStartScreenState extends State { ); } + bool _isFiveMinutesBefore() { + return widget.isFiveMinutesBefore; + } + @override Widget build(BuildContext context) { - return SafeArea( - child: Scaffold( - body: Stack( - children: [ - Column( - children: [ - Padding( - padding: const EdgeInsets.only(top: 60), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context - .read() - .state - .schedule - ?.scheduleName ?? - '', - style: const TextStyle( - fontSize: 40, - fontWeight: FontWeight.bold, - color: Color(0xff5C79FB), + final isFiveMinBefore = _isFiveMinutesBefore(); + + return Container( + color: Colors.white, + child: SafeArea( + child: Scaffold( + body: Stack( + children: [ + Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 60), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context + .read() + .state + .schedule + ?.scheduleName ?? + '', + style: const TextStyle( + fontSize: 40, + fontWeight: FontWeight.bold, + color: Color(0xff5C79FB), + ), ), - ), - const SizedBox(height: 8), - Text( - context - .read() - .state - .schedule - ?.place - .placeName ?? - '', - style: const TextStyle( - fontSize: 25, - fontWeight: FontWeight.bold, + const SizedBox(height: 8), + Text( + context + .read() + .state + .schedule + ?.place + .placeName ?? + '', + style: const TextStyle( + fontSize: 25, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 15), - Text( - AppLocalizations.of(context)!.youWillBeLate, - style: const TextStyle( - fontSize: 15, + const SizedBox(height: 15), + Text( + isFiveMinBefore + ? AppLocalizations.of(context)! + .preparationStartsInFiveMinutes + : AppLocalizations.of(context)!.youWillBeLate, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.grey[950]!, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - Padding( - padding: const EdgeInsets.only(top: 50), - child: SvgPicture.asset( - 'characters/character.svg', - package: 'assets', - width: 204, - height: 269, + Padding( + padding: const EdgeInsets.only(top: 50), + child: SvgPicture.asset( + 'characters/character.svg', + package: 'assets', + width: 204, + height: 269, + ), ), - ), - ], + ], + ), ), ), - ), - const Spacer(), - Padding( - padding: const EdgeInsets.only(bottom: 30), - child: ElevatedButton( - onPressed: () async { - context.go('/alarmScreen'); - }, - child: Text(AppLocalizations.of(context)!.startPreparing), + const Spacer(), + Padding( + padding: const EdgeInsets.only(bottom: 30), + child: isFiveMinBefore + ? _buildTwoButtonLayout(context) + : _buildSingleButton(context), ), + ], + ), + Positioned( + top: 10, + right: 10, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () => _showModal(context), ), - ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildSingleButton(BuildContext context) { + return ElevatedButton( + onPressed: () async { + context.go('/alarmScreen'); + }, + child: Text(AppLocalizations.of(context)!.startPreparing), + ); + } + + Widget _buildTwoButtonLayout(BuildContext context) { + return SizedBox( + width: 358, + height: 127, + child: Column( + children: [ + SizedBox( + width: double.infinity, + height: 57, + child: ElevatedButton( + onPressed: () async { + context.go('/alarmScreen'); + }, + child: Text(AppLocalizations.of(context)!.startPreparing), ), - Positioned( - top: 10, - right: 10, - child: IconButton( - icon: const Icon(Icons.close), - onPressed: () => _showModal(context), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 57, + child: ElevatedButton( + onPressed: () async { + context.go('/home'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: Theme.of(context).colorScheme.primary, ), + child: Text(AppLocalizations.of(context)!.startInFiveMinutes), ), - ], - ), + ), + ], ), ); } diff --git a/lib/presentation/early_late/screens/early_late_screen.dart b/lib/presentation/early_late/screens/early_late_screen.dart index 3e2ddee0..c218ecac 100644 --- a/lib/presentation/early_late/screens/early_late_screen.dart +++ b/lib/presentation/early_late/screens/early_late_screen.dart @@ -23,33 +23,37 @@ class EarlyLateScreen extends StatelessWidget { create: (context) => EarlyLateScreenBloc() ..add(LoadEarlyLateInfo(earlyLateTime: earlyLateTime)) ..add(ChecklistLoaded(checklist: List.generate(3, (index) => false))), - child: Scaffold( - backgroundColor: Colors.white, - body: Stack( - children: [ - BlocBuilder( - builder: (context, state) { - if (state is EarlyLateScreenLoadSuccess) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.only(top: 70), - child: _EarlyLateSection( - earlyLateTime: earlyLateTime, - isLate: isLate, - screenHeight: MediaQuery.of(context).size.height, - earlylateMessage: state.earlylateMessage, - earlylateImage: state.earlylateImage, - ), - ), - ], - ); - } - return const SizedBox.shrink(); - }, + child: Container( + color: Colors.white, + child: SafeArea( + child: Scaffold( + body: Stack( + children: [ + BlocBuilder( + builder: (context, state) { + if (state is EarlyLateScreenLoadSuccess) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 60), + child: _EarlyLateSection( + earlyLateTime: earlyLateTime, + isLate: isLate, + screenHeight: MediaQuery.of(context).size.height, + earlylateMessage: state.earlylateMessage, + earlylateImage: state.earlylateImage, + ), + ), + ], + ); + } + return const SizedBox.shrink(); + }, + ), + const _ButtonSection(), + ], ), - const _ButtonSection(), - ], + ), ), ), ); @@ -166,7 +170,7 @@ class _ButtonSection extends StatelessWidget { @override Widget build(BuildContext context) { return Positioned( - bottom: 20, + bottom: 30, left: 0, right: 0, child: Center( diff --git a/lib/presentation/schedule_create/components/schedule_multi_page_form.dart b/lib/presentation/schedule_create/components/schedule_multi_page_form.dart index 50be14eb..2f18e52a 100644 --- a/lib/presentation/schedule_create/components/schedule_multi_page_form.dart +++ b/lib/presentation/schedule_create/components/schedule_multi_page_form.dart @@ -79,7 +79,7 @@ class _ScheduleMultiPageFormState extends State ], child: Builder(builder: (context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: Column( children: [ TopBar( diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart index 6e528860..ca7ab413 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart @@ -109,11 +109,7 @@ class ScheduleFormSpareTimeCubit extends Cubit { scheduleSpareTime: state.spareTime.value!, )); } - if (state.preparation != null) { - scheduleFormBloc.add(ScheduleFormPreparationChanged( - preparation: state.preparation!, - )); - } + // preparation은 preparationChanged에서 이미 ScheduleFormPreparationChanged를 호출했으므로 여기서는 호출하지 않음 } void preparationChanged(PreparationEntity preparation) { @@ -136,6 +132,10 @@ class ScheduleFormSpareTimeCubit extends Cubit { clearOverlap: overlapCheck.overlapDuration == null, )); + scheduleFormBloc.add(ScheduleFormPreparationChanged( + preparation: preparation, + )); + scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } } diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart index c32d862a..b6a02fb8 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart @@ -34,8 +34,8 @@ class _PreparationFormListFieldState extends State { 'drag_indicator.svg', package: 'assets', semanticsLabel: 'drag indicator', - height: 14, - width: 14, + height: 18, + width: 18, fit: BoxFit.contain, ); @@ -65,7 +65,7 @@ class _PreparationFormListFieldState extends State { padding: const EdgeInsets.only(bottom: 8.0), child: Tile( key: ValueKey(widget.preparationStep.id), - style: TileStyle(padding: EdgeInsets.all(16.0)), + style: TileStyle(padding: EdgeInsets.fromLTRB(21, 19, 21, 19)), leading: widget.index == null ? dragIndicatorSvg : ReorderableDragStartListener( @@ -93,6 +93,8 @@ class _PreparationFormListFieldState extends State { decoration: InputDecoration( isDense: true, border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, contentPadding: EdgeInsets.all(3.0), ), style: textTheme.bodyLarge, diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart index 1feefe2b..ed7c457c 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart @@ -17,13 +17,13 @@ class PreparationTimeInput extends StatelessWidget { children: [ GestureDetector( child: Container( + width: 50, + height: 30, decoration: BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.circular(4), ), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0, vertical: 1.0), + child: Center( child: Text( (time.inMinutes < 10 ? '0' : '') + (time.inMinutes < 0 ? '0' : time.inMinutes.toString()), diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart index d5141faa..96df886e 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart @@ -43,6 +43,7 @@ class _PreparationEditFormState extends State { onPreviousPageButtonClicked: context.pop, isNextButtonEnabled: state.isValid, ), + const SizedBox(height: 24), Expanded( child: PreparationFormCreateList( preparationNameState: state, diff --git a/lib/presentation/schedule_create/screens/schedule_create_screen.dart b/lib/presentation/schedule_create/screens/schedule_create_screen.dart index 18e51cc6..4ffe6a01 100644 --- a/lib/presentation/schedule_create/screens/schedule_create_screen.dart +++ b/lib/presentation/schedule_create/screens/schedule_create_screen.dart @@ -10,8 +10,8 @@ class ScheduleCreateScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return Material( - color: Colors.transparent, + return Container( + color: Colors.white, child: SafeArea( child: FractionallySizedBox( heightFactor: 0.85, From 65bb8ee1dd5ac70a39341dd0a74aab4260055369 Mon Sep 17 00:00:00 2001 From: ya_yo0 Date: Mon, 19 Jan 2026 19:37:49 +0900 Subject: [PATCH 5/6] feat(home): update tile and calendar click interaction logic --- .../calendar/screens/calendar_screen.dart | 184 ++++++++++++++++-- .../home/bloc/weekly_schedules_state.dart | 2 + .../home/components/month_calendar.dart | 46 ++++- .../home/components/todays_schedule_tile.dart | 2 +- .../home/screens/home_screen.dart | 84 +++++--- .../home/screens/home_screen_tmp.dart | 67 ++++--- 6 files changed, 298 insertions(+), 87 deletions(-) diff --git a/lib/presentation/calendar/screens/calendar_screen.dart b/lib/presentation/calendar/screens/calendar_screen.dart index 42f52467..4578a77a 100644 --- a/lib/presentation/calendar/screens/calendar_screen.dart +++ b/lib/presentation/calendar/screens/calendar_screen.dart @@ -7,30 +7,66 @@ import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/calendar/bloc/monthly_schedules_bloc.dart'; import 'package:on_time_front/presentation/calendar/component/schedule_detail.dart'; import 'package:on_time_front/presentation/schedule_create/screens/schedule_edit_screen.dart'; +import 'package:on_time_front/presentation/schedule_create/screens/schedule_create_screen.dart'; import 'package:on_time_front/presentation/shared/components/calendar/centered_calendar_header.dart'; import 'package:on_time_front/presentation/shared/theme/calendar_theme.dart'; import 'package:table_calendar/table_calendar.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; class CalendarScreen extends StatefulWidget { - const CalendarScreen({super.key}); + const CalendarScreen({super.key, this.initialDate}); + + final DateTime? initialDate; @override State createState() => _CalendarScreenState(); } class _CalendarScreenState extends State { - DateTime _selectedDate = - DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day); + late DateTime _selectedDate; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + final lastDay = DateTime(2026, 12, 31); + + if (widget.initialDate != null) { + _selectedDate = DateTime( + widget.initialDate!.year, + widget.initialDate!.month, + widget.initialDate!.day, + ); + } else { + _selectedDate = now.isAfter(lastDay) + ? lastDay + : DateTime(now.year, now.month, now.day); + } + } void _onLeftArrowTap() { + final DateTime firstDay = DateTime(2024, 12, 1); + final DateTime nextSelectedDate = + DateTime(_selectedDate.year, _selectedDate.month - 1, 1); + + final DateTime clampedSelectedDate = + nextSelectedDate.isBefore(firstDay) ? firstDay : nextSelectedDate; + setState(() { - _selectedDate = DateTime(_selectedDate.year, _selectedDate.month - 1, 1); + _selectedDate = clampedSelectedDate; }); } void _onRightArrowTap() { + final DateTime lastDay = DateTime(2026, 12, 31); + final DateTime nextSelectedDate = + DateTime(_selectedDate.year, _selectedDate.month + 1, 1); + + final DateTime clampedSelectedDate = + nextSelectedDate.isAfter(lastDay) ? lastDay : nextSelectedDate; + setState(() { - _selectedDate = DateTime(_selectedDate.year, _selectedDate.month + 1, 1); + _selectedDate = clampedSelectedDate; }); } @@ -39,13 +75,19 @@ class _CalendarScreenState extends State { final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; - final todaysDate = DateTime( - DateTime.now().year, DateTime.now().month, DateTime.now().day, 0, 0, 0); final calendarTheme = theme.extension()!; return BlocProvider( create: (context) => getIt.get() - ..add(MonthlySchedulesSubscriptionRequested(date: todaysDate)), + ..add(MonthlySchedulesSubscriptionRequested( + date: DateTime( + _selectedDate.year, + _selectedDate.month, + _selectedDate.day, + 0, + 0, + 0, + ))), child: Scaffold( backgroundColor: colorScheme.surfaceContainerLow, appBar: AppBar( @@ -86,28 +128,54 @@ class _CalendarScreenState extends State { }, focusedDay: _selectedDate, firstDay: DateTime(2024, 12, 1), - lastDay: DateTime(2025, 12, 31), + lastDay: DateTime(2026, 12, 31), calendarFormat: CalendarFormat.month, headerStyle: calendarTheme.headerStyle, daysOfWeekStyle: calendarTheme.daysOfWeekStyle, calendarStyle: calendarTheme.calendarStyle, onDaySelected: (selectedDay, focusedDay) { + final DateTime firstDay = DateTime(2024, 12, 1); + final DateTime lastDay = DateTime(2026, 12, 31); + + DateTime clampedSelectedDate = DateTime( + selectedDay.year, + selectedDay.month, + selectedDay.day); + + if (clampedSelectedDate.isBefore(firstDay)) { + clampedSelectedDate = firstDay; + } else if (clampedSelectedDate.isAfter(lastDay)) { + clampedSelectedDate = lastDay; + } + setState(() { - _selectedDate = DateTime(selectedDay.year, - selectedDay.month, selectedDay.day); + _selectedDate = clampedSelectedDate; }); - debugPrint(selectedDay.toIso8601String()); + debugPrint(clampedSelectedDate.toIso8601String()); }, onPageChanged: (focusedDay) { + final DateTime firstDay = DateTime(2024, 12, 1); + final DateTime lastDay = DateTime(2026, 12, 31); + + DateTime clampedFocusedDay = DateTime(focusedDay.year, + focusedDay.month, focusedDay.day); + + if (clampedFocusedDay.isBefore(firstDay)) { + clampedFocusedDay = firstDay; + } else if (clampedFocusedDay.isAfter(lastDay)) { + clampedFocusedDay = lastDay; + } + setState(() { - _selectedDate = DateTime(focusedDay.year, - focusedDay.month, focusedDay.day); + _selectedDate = clampedFocusedDay; }); debugPrint(_selectedDate.toIso8601String()); context.read().add( MonthlySchedulesMonthAdded( - date: DateTime(focusedDay.year, - focusedDay.month, focusedDay.day))); + date: DateTime( + clampedFocusedDay.year, + clampedFocusedDay.month, + clampedFocusedDay.day))); }, calendarBuilders: CalendarBuilders( headerTitleBuilder: (context, date) { @@ -142,7 +210,7 @@ class _CalendarScreenState extends State { ), ), ), - const SizedBox(height: 18.0), + const SizedBox(height: 32.0), BlocBuilder( builder: (context, state) { if (state.schedules[_selectedDate]?.isEmpty ?? true) { @@ -151,13 +219,91 @@ class _CalendarScreenState extends State { } else if (state.status != MonthlySchedulesStatus.success) { return const SizedBox(); } else { - return Text(AppLocalizations.of(context)!.noSchedules); + // Empty-state UI with date title and action box + final dateText = + DateFormat('M월 d일', 'ko').format(_selectedDate); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dateText, + style: textTheme.headlineExtraSmall, + ), + const SizedBox(height: 24.0), + Container( + width: double.infinity, + height: 148, + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '약속이 없어요', + style: textTheme.titleMedium?.copyWith( + color: colorScheme.outlineVariant, + ) ?? + TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + height: 1.4, + color: colorScheme.outlineVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32.0), + SizedBox( + width: 149, + height: 43, + child: ElevatedButton( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => + ScheduleCreateScreen(), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 8, + ), + ), + child: Text( + '약속 추가하기', + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + height: 1.4, + color: colorScheme.onPrimary, + ) ?? + TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.4, + color: colorScheme.onPrimary, + ), + ), + ), + ), + ], + ), + ), + ], + ); } } return Expanded( - child: ListView.builder( + child: ListView.separated( itemCount: state.schedules[_selectedDate]?.length ?? 0, + separatorBuilder: (_, __) => const SizedBox(height: 16), itemBuilder: (context, index) { final schedule = state.schedules[_selectedDate]![index]; return ScheduleDetail( diff --git a/lib/presentation/home/bloc/weekly_schedules_state.dart b/lib/presentation/home/bloc/weekly_schedules_state.dart index 7a9d5cdf..e577b3b4 100644 --- a/lib/presentation/home/bloc/weekly_schedules_state.dart +++ b/lib/presentation/home/bloc/weekly_schedules_state.dart @@ -13,6 +13,8 @@ final class WeeklySchedulesState extends Equatable { schedules.map((schedule) => schedule.scheduleTime).toList(); ScheduleEntity? get todaySchedule => schedules .where((schedule) { + if (schedule.doneStatus != ScheduleDoneStatus.notEnded) return false; + final now = DateTime.now(); return schedule.scheduleTime.year == now.year && schedule.scheduleTime.month == now.month && diff --git a/lib/presentation/home/components/month_calendar.dart b/lib/presentation/home/components/month_calendar.dart index d93df054..72368bf1 100644 --- a/lib/presentation/home/components/month_calendar.dart +++ b/lib/presentation/home/components/month_calendar.dart @@ -10,10 +10,12 @@ class MonthCalendar extends StatefulWidget { super.key, required this.monthlySchedulesState, this.dispatchBlocEvents = true, + this.onDateSelected, }); final MonthlySchedulesState monthlySchedulesState; final bool dispatchBlocEvents; + final void Function(DateTime)? onDateSelected; @override State createState() => _MonthCalendarState(); @@ -25,30 +27,42 @@ class _MonthCalendarState extends State { @override void initState() { super.initState(); - _focusedDay = DateTime.now(); + final now = DateTime.now(); + final lastDay = DateTime(2026, 12, 31); + _focusedDay = now.isAfter(lastDay) ? lastDay : now; } void _onLeftArrowTap() { + final DateTime firstDay = DateTime(2024, 1, 1); final DateTime nextFocusedDay = DateTime(_focusedDay.year, _focusedDay.month - 1, 1); + + final DateTime clampedFocusedDay = + nextFocusedDay.isBefore(firstDay) ? firstDay : nextFocusedDay; + setState(() { - _focusedDay = nextFocusedDay; + _focusedDay = clampedFocusedDay; }); if (widget.dispatchBlocEvents) { context.read().add(MonthlySchedulesMonthAdded( - date: DateTime(nextFocusedDay.year, nextFocusedDay.month, 1))); + date: DateTime(clampedFocusedDay.year, clampedFocusedDay.month, 1))); } } void _onRightArrowTap() { + final DateTime lastDay = DateTime(2026, 12, 31); final DateTime nextFocusedDay = DateTime(_focusedDay.year, _focusedDay.month + 1, 1); + + final DateTime clampedFocusedDay = + nextFocusedDay.isAfter(lastDay) ? lastDay : nextFocusedDay; + setState(() { - _focusedDay = nextFocusedDay; + _focusedDay = clampedFocusedDay; }); if (widget.dispatchBlocEvents) { context.read().add(MonthlySchedulesMonthAdded( - date: DateTime(nextFocusedDay.year, nextFocusedDay.month, 1))); + date: DateTime(clampedFocusedDay.year, clampedFocusedDay.month, 1))); } } @@ -74,23 +88,35 @@ class _MonthCalendarState extends State { availableGestures: AvailableGestures.none, focusedDay: _focusedDay, firstDay: DateTime(2024, 1, 1), - lastDay: DateTime(2025, 12, 31), + lastDay: DateTime(2026, 12, 31), calendarFormat: CalendarFormat.month, headerStyle: calendarTheme.headerStyle, daysOfWeekStyle: calendarTheme.daysOfWeekStyle, daysOfWeekHeight: 40, calendarStyle: calendarTheme.calendarStyle, onDaySelected: (selectedDay, focusedDay) { - // Handle day selection if needed + if (widget.onDateSelected != null) { + widget.onDateSelected!(selectedDay); + } }, onPageChanged: (focusedDay) { + final DateTime firstDay = DateTime(2024, 1, 1); + final DateTime lastDay = DateTime(2026, 12, 31); + + DateTime clampedFocusedDay = focusedDay; + if (focusedDay.isBefore(firstDay)) { + clampedFocusedDay = firstDay; + } else if (focusedDay.isAfter(lastDay)) { + clampedFocusedDay = lastDay; + } + setState(() { - _focusedDay = focusedDay; + _focusedDay = clampedFocusedDay; }); if (widget.dispatchBlocEvents) { context.read().add(MonthlySchedulesMonthAdded( - date: DateTime( - focusedDay.year, focusedDay.month, focusedDay.day))); + date: DateTime(clampedFocusedDay.year, clampedFocusedDay.month, + clampedFocusedDay.day))); } }, calendarBuilders: CalendarBuilders( diff --git a/lib/presentation/home/components/todays_schedule_tile.dart b/lib/presentation/home/components/todays_schedule_tile.dart index f56445bf..2153fa4d 100644 --- a/lib/presentation/home/components/todays_schedule_tile.dart +++ b/lib/presentation/home/components/todays_schedule_tile.dart @@ -62,7 +62,7 @@ class TodaysScheduleTile extends StatelessWidget { final theme = Theme.of(context); return InkWell( borderRadius: BorderRadius.circular(8), - onTap: schedule == null ? null : onTap, + onTap: onTap, child: Container( decoration: BoxDecoration( color: schedule == null diff --git a/lib/presentation/home/screens/home_screen.dart b/lib/presentation/home/screens/home_screen.dart index cd063837..96261373 100644 --- a/lib/presentation/home/screens/home_screen.dart +++ b/lib/presentation/home/screens/home_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; import 'package:on_time_front/presentation/home/bloc/weekly_schedules_bloc.dart'; import 'package:on_time_front/presentation/home/components/todays_schedule_tile.dart'; import 'package:on_time_front/presentation/home/components/week_calendar.dart'; @@ -34,36 +35,43 @@ class _HomeScreenState extends State { return BlocProvider( create: (context) => getIt.get() ..add(WeeklySchedulesSubscriptionRequested(date: dateOfToday)), - child: BlocBuilder( - builder: (context, state) { - return Container( - color: AppColors.white, - child: Column( - children: [ - SizedBox(height: 58.0), - Stack( - alignment: Alignment.bottomCenter, - children: [ - _PunctualityIndicator(score: score), - todaysScheduleOverlayBuilder(state), - ], - ), - Expanded( - child: Container( - padding: const EdgeInsets.only( - top: 50.0, left: 16.0, right: 16.0), - decoration: BoxDecoration( - color: AppColors.blue[100], - ), - child: _WeeklySchedule( - weeklySchedulesState: state, + child: BlocListener( + listener: (context, scheduleState) { + if (scheduleState.status == ScheduleStatus.started) { + context.go('/scheduleStart'); + } + }, + child: BlocBuilder( + builder: (context, state) { + return Container( + color: AppColors.white, + child: Column( + children: [ + SizedBox(height: 58.0), + Stack( + alignment: Alignment.bottomCenter, + children: [ + _PunctualityIndicator(score: score), + todaysScheduleOverlayBuilder(state), + ], + ), + Expanded( + child: Container( + padding: const EdgeInsets.only( + top: 50.0, left: 16.0, right: 16.0), + decoration: BoxDecoration( + color: AppColors.blue[100], + ), + child: _WeeklySchedule( + weeklySchedulesState: state, + ), ), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), ), ); } @@ -97,9 +105,19 @@ class _HomeScreenState extends State { style: theme.textTheme.titleMedium, ), SizedBox(height: 21.0), - TodaysScheduleTile( - schedule: state.todaySchedule, - onTap: () => context.go('/alarmScreen'), + BlocBuilder( + builder: (context, scheduleState) { + final isScheduleReady = + scheduleState.status == ScheduleStatus.ongoing || + scheduleState.status == ScheduleStatus.started; + final hasSchedule = state.todaySchedule != null; + return TodaysScheduleTile( + schedule: state.todaySchedule, + onTap: isScheduleReady && hasSchedule + ? () => context.go('/alarmScreen') + : null, + ); + }, ) ], ), @@ -192,7 +210,9 @@ class _WeekCalendar extends StatelessWidget { return WeekCalendar( date: DateTime.now(), - onDateSelected: (date) {}, + onDateSelected: (date) { + context.go('/calendar', extra: date); + }, highlightedDates: weeklySchedulesState.dates, ); } diff --git a/lib/presentation/home/screens/home_screen_tmp.dart b/lib/presentation/home/screens/home_screen_tmp.dart index 41da7b4f..0b251560 100644 --- a/lib/presentation/home/screens/home_screen_tmp.dart +++ b/lib/presentation/home/screens/home_screen_tmp.dart @@ -50,32 +50,39 @@ class HomeScreenContent extends StatelessWidget { bloc.state.user.mapOrNull((user) => user.score) ?? -1); final colorScheme = Theme.of(context).colorScheme; - return SingleChildScrollView( - physics: const ClampingScrollPhysics(), - child: Column( - mainAxisSize: MainAxisSize.max, - children: [ - Container( - color: colorScheme.primary, - padding: const EdgeInsets.only(top: 58.0), - child: Column( - children: [ - _CharacterSection(score: score), - _TodaysScheduleOverlay(), - ], - ), - ), - Container( - padding: const EdgeInsets.only( - top: 0.0, left: 16.0, right: 16.0, bottom: 24.0), - decoration: BoxDecoration( - color: colorScheme.surface, + return BlocListener( + listener: (context, scheduleState) { + if (scheduleState.status == ScheduleStatus.started) { + context.go('/scheduleStart'); + } + }, + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Container( + color: colorScheme.primary, + padding: const EdgeInsets.only(top: 58.0), + child: Column( + children: [ + _CharacterSection(score: score), + _TodaysScheduleOverlay(), + ], + ), ), - child: _MonthlySchedule( - monthlySchedulesState: state, + Container( + padding: const EdgeInsets.only( + top: 0.0, left: 16.0, right: 16.0, bottom: 24.0), + decoration: BoxDecoration( + color: colorScheme.surface, + ), + child: _MonthlySchedule( + monthlySchedulesState: state, + ), ), - ), - ], + ], + ), ), ); } @@ -97,6 +104,11 @@ class _TodaysScheduleOverlay extends StatelessWidget { ? null : scheduleState.schedule; + final isScheduleReady = + scheduleState.status == ScheduleStatus.ongoing || + scheduleState.status == ScheduleStatus.started; + final hasSchedule = todaySchedule != null; + return Stack( alignment: Alignment.bottomCenter, children: [ @@ -136,7 +148,9 @@ class _TodaysScheduleOverlay extends StatelessWidget { SizedBox(height: 21.0), TodaysScheduleTile( schedule: todaySchedule, - onTap: () => context.go('/alarmScreen'), + onTap: isScheduleReady && hasSchedule + ? () => context.go('/alarmScreen') + : null, ) ], ), @@ -165,6 +179,9 @@ class _MonthlySchedule extends StatelessWidget { _MonthlyScheduleHeader(), MonthCalendar( monthlySchedulesState: monthlySchedulesState, + onDateSelected: (date) { + context.go('/calendar', extra: date); + }, ), ], ); From a6823dd6b5674802fdf1e3ee946997f398540a50 Mon Sep 17 00:00:00 2001 From: ya_yo0 Date: Mon, 19 Jan 2026 19:38:11 +0900 Subject: [PATCH 6/6] build(permission): update dependencies and notification permission logic --- android/app/src/main/AndroidManifest.xml | 3 + ios/Podfile.lock | 8 +- lib/presentation/my_page/my_page_screen.dart | 270 +++++++++++++++++- .../screens/notification_allow_screen.dart | 106 +++++-- pubspec.lock | 70 ++++- pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 8 files changed, 433 insertions(+), 29 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 37b89e8a..e4b0633f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + _handleNotificationPermission(BuildContext context) async { + final notificationService = NotificationService.instance; + final currentStatus = await notificationService.checkNotificationPermission(); + + if (!context.mounted) return; + + if (currentStatus == AuthorizationStatus.authorized) { + await _showAlreadyEnabledDialog(context); + } else if (currentStatus == AuthorizationStatus.denied) { + final shouldRequest = await _showPermissionRationaleDialog(context); + if (shouldRequest == true && context.mounted) { + final newStatus = await notificationService.requestPermission(); + + if (!context.mounted) return; + + if (newStatus == AuthorizationStatus.authorized) { + await notificationService.initialize(); + await _showPermissionGrantedDialog(context); + } else if (newStatus == AuthorizationStatus.denied) { + await _showGoToSettingsDialog(context); + } + } + } else if (currentStatus == AuthorizationStatus.notDetermined) { + final shouldRequest = await _showPermissionRationaleDialog(context); + if (shouldRequest == true && context.mounted) { + final newStatus = await notificationService.requestPermission(); + + if (!context.mounted) return; + + if (newStatus == AuthorizationStatus.authorized) { + await notificationService.initialize(); + await _showPermissionGrantedDialog(context); + } else if (newStatus == AuthorizationStatus.denied) { + await _showGoToSettingsDialog(context); + } + } + } else { + await _showGoToSettingsDialog(context); + } +} + +Future _showAlreadyEnabledDialog(BuildContext context) async { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final l10n = AppLocalizations.of(context)!; + + return showDialog( + context: context, + builder: (context) => CustomAlertDialog.error( + title: Text( + l10n.notificationAlreadyEnabled, + style: textTheme.titleMedium?.copyWith( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + height: 1.4, + fontSize: 18, + color: colorScheme.onSurface, + ), + ), + content: Text( + l10n.notificationAlreadyEnabledDescription, + style: textTheme.bodyMedium?.copyWith( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + height: 1.4, + fontSize: 14, + color: colorScheme.outline, + ), + ), + actions: [ + ModalButton( + onPressed: () => Navigator.of(context).pop(), + text: l10n.ok, + color: colorScheme.primary, + textStyle: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + height: 1.4, + color: colorScheme.onPrimary, + ), + ), + ], + actionsAlignment: MainAxisAlignment.center, + innerPadding: const EdgeInsets.fromLTRB(16, 18, 16, 18), + ), + ); +} + +Future _showPermissionRationaleDialog(BuildContext context) async { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final l10n = AppLocalizations.of(context)!; + + return showDialog( + context: context, + builder: (context) => CustomAlertDialog.error( + title: Text( + l10n.notificationPermissionRequired, + style: textTheme.titleMedium?.copyWith( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + height: 1.4, + fontSize: 18, + color: colorScheme.onSurface, + ), + ), + content: Text( + l10n.notificationPermissionRequiredDescription, + style: textTheme.bodyMedium?.copyWith( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + height: 1.4, + fontSize: 14, + color: colorScheme.outline, + ), + ), + actions: [ + ModalButton( + onPressed: () => Navigator.of(context).pop(false), + text: l10n.cancel, + color: colorScheme.surfaceContainerLow, + textStyle: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + height: 1.4, + color: colorScheme.outline, + ), + ), + const SizedBox(width: 8), + ModalButton( + onPressed: () => Navigator.of(context).pop(true), + text: l10n.allow, + color: colorScheme.primary, + textStyle: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + height: 1.4, + color: colorScheme.onPrimary, + ), + ), + ], + actionsAlignment: MainAxisAlignment.center, + innerPadding: const EdgeInsets.fromLTRB(16, 18, 16, 18), + ), + ); +} + +Future _showPermissionGrantedDialog(BuildContext context) async { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final l10n = AppLocalizations.of(context)!; + + return showDialog( + context: context, + builder: (context) => CustomAlertDialog.error( + title: Text( + l10n.notificationPermissionGranted, + style: textTheme.titleMedium?.copyWith( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + height: 1.4, + fontSize: 18, + color: colorScheme.onSurface, + ), + ), + content: Text( + l10n.notificationPermissionGrantedDescription, + style: textTheme.bodyMedium?.copyWith( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + height: 1.4, + fontSize: 14, + color: colorScheme.outline, + ), + ), + actions: [ + ModalButton( + onPressed: () => Navigator.of(context).pop(), + text: l10n.ok, + color: colorScheme.primary, + textStyle: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + height: 1.4, + color: colorScheme.onPrimary, + ), + ), + ], + actionsAlignment: MainAxisAlignment.center, + innerPadding: const EdgeInsets.fromLTRB(16, 18, 16, 18), + ), + ); +} + +Future _showGoToSettingsDialog(BuildContext context) async { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final l10n = AppLocalizations.of(context)!; + + return showDialog( + context: context, + builder: (context) => CustomAlertDialog.error( + title: Text( + l10n.openNotificationSettings, + style: textTheme.titleMedium?.copyWith( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + height: 1.4, + fontSize: 18, + color: colorScheme.onSurface, + ), + ), + content: Text( + l10n.openNotificationSettingsDescription, + style: textTheme.bodyMedium?.copyWith( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + height: 1.4, + fontSize: 14, + color: colorScheme.outline, + ), + ), + actions: [ + ModalButton( + onPressed: () => Navigator.of(context).pop(), + text: l10n.cancel, + color: colorScheme.surfaceContainerLow, + textStyle: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + height: 1.4, + color: colorScheme.outline, + ), + ), + const SizedBox(width: 8), + ModalButton( + onPressed: () async { + Navigator.of(context).pop(); + await NotificationService.instance.openNotificationSettings(); + }, + text: l10n.openSettings, + color: colorScheme.primary, + textStyle: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + height: 1.4, + color: colorScheme.onPrimary, + ), + ), + ], + actionsAlignment: MainAxisAlignment.center, + innerPadding: const EdgeInsets.fromLTRB(16, 18, 16, 18), + ), + ); +} diff --git a/lib/presentation/notification_allow/screens/notification_allow_screen.dart b/lib/presentation/notification_allow/screens/notification_allow_screen.dart index 1ca1f81a..3b3f5fca 100644 --- a/lib/presentation/notification_allow/screens/notification_allow_screen.dart +++ b/lib/presentation/notification_allow/screens/notification_allow_screen.dart @@ -4,6 +4,8 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/shared/components/custom_alert_dialog.dart'; +import 'package:on_time_front/presentation/shared/components/modal_button.dart'; import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; class NotificationAllowScreen extends StatelessWidget { @@ -56,13 +58,7 @@ class _Buttons extends StatelessWidget { children: [ FilledButton( onPressed: () async { - await NotificationService.instance.initialize(); - final permission = await NotificationService.instance - .checkNotificationPermission(); - NotificationService.instance.requestNotificationToken(); - if (permission == AuthorizationStatus.authorized) { - context.go('/home'); - } + await _handleNotificationPermission(context); }, child: Text( AppLocalizations.of(context)!.allowNotifications, @@ -72,15 +68,20 @@ class _Buttons extends StatelessWidget { ), ), ), - SizedBox( - width: 358, - child: Text( - AppLocalizations.of(context)!.doItLater, - textAlign: TextAlign.center, - style: textTheme.bodyLarge?.copyWith( - color: AppColors.grey[400], - decoration: TextDecoration.underline, - decorationColor: AppColors.grey[400], + GestureDetector( + onTap: () { + context.go('/home'); + }, + child: SizedBox( + width: 358, + child: Text( + AppLocalizations.of(context)!.doItLater, + textAlign: TextAlign.center, + style: textTheme.bodyLarge?.copyWith( + color: AppColors.grey[400], + decoration: TextDecoration.underline, + decorationColor: AppColors.grey[400], + ), ), ), ), @@ -151,3 +152,76 @@ class _Image extends StatelessWidget { ); } } + +Future _handleNotificationPermission(BuildContext context) async { + final notificationService = NotificationService.instance; + final currentStatus = await notificationService.checkNotificationPermission(); + + if (!context.mounted) return; + + if (currentStatus == AuthorizationStatus.authorized) { + await notificationService.initialize(); + if (context.mounted) { + context.go('/home'); + } + } else if (currentStatus == AuthorizationStatus.denied) { + final shouldOpenSettings = await _showGoToSettingsDialog(context); + if (shouldOpenSettings == true) { + await notificationService.openNotificationSettings(); + } + } else if (currentStatus == AuthorizationStatus.notDetermined) { + final newStatus = await notificationService.requestPermission(); + + if (!context.mounted) return; + + if (newStatus == AuthorizationStatus.authorized) { + await notificationService.initialize(); + if (context.mounted) { + context.go('/home'); + } + } else if (newStatus == AuthorizationStatus.denied) { + final shouldOpenSettings = await _showGoToSettingsDialog(context); + if (shouldOpenSettings == true) { + await notificationService.openNotificationSettings(); + } + } + } else { + final shouldOpenSettings = await _showGoToSettingsDialog(context); + if (shouldOpenSettings == true) { + await notificationService.openNotificationSettings(); + } + } +} + +Future _showGoToSettingsDialog(BuildContext context) async { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final l10n = AppLocalizations.of(context)!; + + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => CustomAlertDialog( + title: Text(l10n.openNotificationSettings), + content: Text(l10n.openNotificationSettingsDescription), + actions: [ + ModalButton( + onPressed: () => Navigator.of(context).pop(false), + text: l10n.doItLater, + color: colorScheme.surfaceContainerHighest, + textStyle: textTheme.titleSmall?.copyWith( + color: colorScheme.onSurface, + ), + ), + ModalButton( + onPressed: () => Navigator.of(context).pop(true), + text: l10n.openSettings, + color: colorScheme.primary, + textStyle: textTheme.titleSmall?.copyWith( + color: colorScheme.onPrimary, + ), + ), + ], + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock index da69ea3f..9b7cdaa5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -848,26 +848,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -1004,6 +1004,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -1309,10 +1357,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" timezone: dependency: transitive description: @@ -1437,10 +1485,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" verbose: dependency: transitive description: @@ -1570,5 +1618,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.7.0-0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index b483894f..23f96ee1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,6 +84,7 @@ dependencies: intl: ^0.20.2 sign_in_with_apple: ^7.0.1 + permission_handler: ^12.0.1 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index aa588b48..301c4b55 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 6967ac89..f03048fc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST firebase_core flutter_secure_storage_windows + permission_handler_windows sqlite3_flutter_libs url_launcher_windows )