diff --git a/KkuMulKum/Application/AppDelegate.swift b/KkuMulKum/Application/AppDelegate.swift index 9f9e5431..1cae8b89 100644 --- a/KkuMulKum/Application/AppDelegate.swift +++ b/KkuMulKum/Application/AppDelegate.swift @@ -19,6 +19,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + + UNUserNotificationCenter.current().delegate = LocalNotificationManager.shared + // KakaoSDK 초기화 과정에서 앱 키를 동적으로 불러오기 if let kakaoAppKey = fetchKakaoAppKeyFromPrivacyInfo() { KakaoSDK.initSDK(appKey: kakaoAppKey) diff --git a/KkuMulKum/Resource/Notification/LocalNotificationManager.swift b/KkuMulKum/Resource/Notification/LocalNotificationManager.swift index 14692283..5fff6f38 100644 --- a/KkuMulKum/Resource/Notification/LocalNotificationManager.swift +++ b/KkuMulKum/Resource/Notification/LocalNotificationManager.swift @@ -6,33 +6,70 @@ // import Foundation + import UserNotifications -class LocalNotificationManager { - +class LocalNotificationManager: NSObject, UNUserNotificationCenterDelegate { + static let shared = LocalNotificationManager() private let notificationCenter = UNUserNotificationCenter.current() - + + private override init() { + super.init() + notificationCenter.delegate = self + } + func requestAuthorization(completion: @escaping (Bool) -> Void) { notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in - completion(granted) + if let error = error { + print("Authorization request error: \(error)") + } + DispatchQueue.main.async { + completion(granted) + } } } - func scheduleNotification(title: String, body: String, triggerDate: Date) { + func scheduleNotification(title: String, body: String, triggerDate: Date, identifier: String, + completion: @escaping ( + Error? + ) -> Void + ) { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default - let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: triggerDate) + let components = Calendar.current.dateComponents( + [.year, .month,.day, .hour, .minute, .second ], + from: triggerDate + ) let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) notificationCenter.add(request) { error in - if let error = error { - print("Error scheduling notification: \(error)") + DispatchQueue.main.async { + completion(error) + } + } + } + + func getPendingNotifications(completion: @escaping ([UNNotificationRequest]) -> Void) { + notificationCenter.getPendingNotificationRequests { requests in + DispatchQueue.main.async { + completion(requests) } } } + + func removeAllPendingNotifications() { + notificationCenter.removeAllPendingNotificationRequests() + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound, .badge]) + } } diff --git a/KkuMulKum/Source/AddPromise/ViewController/AddPromiseViewController.swift b/KkuMulKum/Source/AddPromise/ViewController/AddPromiseViewController.swift index 252c29a3..a26b2e4b 100644 --- a/KkuMulKum/Source/AddPromise/ViewController/AddPromiseViewController.swift +++ b/KkuMulKum/Source/AddPromise/ViewController/AddPromiseViewController.swift @@ -105,7 +105,7 @@ final class AddPromiseViewController: BaseViewController { name: owner.viewModel.name, place: place, promiseDateString: owner.viewModel.combinedDateTime, - service: MockSelectMemberService() + service: MeetingService() ) ) owner.navigationController?.pushViewController(viewController, animated: true) diff --git a/KkuMulKum/Source/AddPromise/ViewController/SelectMemberViewController.swift b/KkuMulKum/Source/AddPromise/ViewController/SelectMemberViewController.swift index 95012876..6644fb3c 100644 --- a/KkuMulKum/Source/AddPromise/ViewController/SelectMemberViewController.swift +++ b/KkuMulKum/Source/AddPromise/ViewController/SelectMemberViewController.swift @@ -57,7 +57,7 @@ final class SelectMemberViewController: BaseViewController { place: owner.viewModel.place, dateString: owner.viewModel.promiseDateString, members: owner.viewModel.members, - service: MockSelectPenaltyService() + service: PromiseService() ) ) owner.navigationController?.pushViewController(viewController, animated: true) diff --git a/KkuMulKum/Source/Promise/ReadyStatus/View/ReadyStatusView.swift b/KkuMulKum/Source/Promise/ReadyStatus/View/ReadyStatusView.swift index 424e143e..3b8c499a 100644 --- a/KkuMulKum/Source/Promise/ReadyStatus/View/ReadyStatusView.swift +++ b/KkuMulKum/Source/Promise/ReadyStatus/View/ReadyStatusView.swift @@ -92,6 +92,7 @@ class ReadyStatusView: BaseView { scrollView.addSubview(contentView) addSubviews(scrollView) + } override func setupAutoLayout() { @@ -100,19 +101,23 @@ class ReadyStatusView: BaseView { } contentView.snp.makeConstraints { - $0.edges.width.equalToSuperview() - $0.height.greaterThanOrEqualToSuperview() + $0.edges.equalTo(scrollView.contentLayoutGuide) + $0.width.equalTo(scrollView.frameLayoutGuide) } baseStackView.snp.makeConstraints { $0.top.equalToSuperview().offset(24) - $0.horizontalEdges.equalToSuperview().inset(20) + $0.leading.trailing.equalToSuperview().inset(20) } ourReadyStatusCollectionView.snp.makeConstraints { $0.top.equalTo(baseStackView.snp.bottom).offset(22) - $0.horizontalEdges.equalToSuperview().inset(20) - $0.bottom.equalToSuperview().inset(20) + $0.leading.trailing.equalToSuperview().inset(20) + $0.height.equalTo(Screen.height(72) * 2) + } + + contentView.snp.makeConstraints { + $0.bottom.equalTo(ourReadyStatusCollectionView.snp.bottom).offset(20) } } } diff --git a/KkuMulKum/Source/Promise/ReadyStatus/ViewController/ReadyStatusViewController.swift b/KkuMulKum/Source/Promise/ReadyStatus/ViewController/ReadyStatusViewController.swift index 78a267df..fd2ed375 100644 --- a/KkuMulKum/Source/Promise/ReadyStatus/ViewController/ReadyStatusViewController.swift +++ b/KkuMulKum/Source/Promise/ReadyStatus/ViewController/ReadyStatusViewController.swift @@ -189,6 +189,12 @@ private extension ReadyStatusViewController { readyStatusViewModel.participantInfos.bind(with: self) { owner, participants in DispatchQueue.main.async { owner.rootView.ourReadyStatusCollectionView.reloadData() + + owner.rootView.ourReadyStatusCollectionView.snp.updateConstraints { + $0.height.equalTo( + CGFloat(participants.count) * Screen.height(72) + ) + } } } diff --git a/KkuMulKum/Source/Promise/ReadyStatus/ViewController/SetReadyInfoViewController.swift b/KkuMulKum/Source/Promise/ReadyStatus/ViewController/SetReadyInfoViewController.swift index 3ba7065e..7109cc60 100644 --- a/KkuMulKum/Source/Promise/ReadyStatus/ViewController/SetReadyInfoViewController.swift +++ b/KkuMulKum/Source/Promise/ReadyStatus/ViewController/SetReadyInfoViewController.swift @@ -27,6 +27,7 @@ final class SetReadyInfoViewController: BaseViewController { fatalError("init(coder:) has not been implemented") } + // MARK: - LifeCycle override func viewWillAppear(_ animated: Bool) { @@ -47,6 +48,7 @@ final class SetReadyInfoViewController: BaseViewController { setupNavigationBarTitle(with: "준비 정보 입력하기") bindViewModel() + setupTapGesture() } override func setupDelegate() { @@ -95,6 +97,18 @@ final class SetReadyInfoViewController: BaseViewController { private func doneButtonDidTap(_ sender: UIButton) { viewModel.updateReadyInfo() } + + + // MARK: - Keyboard Dismissal + + private func setupTapGesture() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + view.addGestureRecognizer(tapGesture) + } + + @objc private func dismissKeyboard() { + view.endEditing(true) + } } @@ -148,10 +162,9 @@ private extension SetReadyInfoViewController { Toast().show(message: message, view: view, position: .bottom, inset: bottomInset) } - // MARK: - Data Bind - func bindViewModel() { + func bindViewModel() { viewModel.readyHour.bind { [weak self] readyHour in self?.rootView.readyHourTextField.text = readyHour } diff --git a/KkuMulKum/Source/Promise/ReadyStatus/ViewModel/SetReadyInfoViewModel.swift b/KkuMulKum/Source/Promise/ReadyStatus/ViewModel/SetReadyInfoViewModel.swift index bd4ce9b5..10dd03de 100644 --- a/KkuMulKum/Source/Promise/ReadyStatus/ViewModel/SetReadyInfoViewModel.swift +++ b/KkuMulKum/Source/Promise/ReadyStatus/ViewModel/SetReadyInfoViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import UserNotifications final class SetReadyInfoViewModel { let promiseID: Int @@ -21,22 +22,24 @@ final class SetReadyInfoViewModel { let moveMinute = ObservablePattern("") let isSucceedToSave = ObservablePattern(false) - // TODO: 준비 및 이동 시간 분 단위로 계산 var readyTime: Int = 0 var moveTime: Int = 0 private let service: SetReadyStatusInfoServiceType + private let notificationManager: LocalNotificationManager init( promiseID: Int, promiseTime: String, promiseName: String, - service: SetReadyStatusInfoServiceType + service: SetReadyStatusInfoServiceType, + notificationManager: LocalNotificationManager = LocalNotificationManager.shared ) { self.promiseID = promiseID self.promiseName = promiseName self.promiseTime = promiseTime self.service = service + self.notificationManager = notificationManager } private func validTime(time: Int, range: ClosedRange, defaultValue: String) -> String { @@ -77,8 +80,7 @@ final class SetReadyInfoViewModel { calculateTimes() } - func checkValid( - readyHourText: String, + func checkValid(readyHourText: String, readyMinuteText: String, moveHourText: String, moveMinuteText: String @@ -92,11 +94,10 @@ final class SetReadyInfoViewModel { } func updateReadyInfo() { - // 확인 버튼이 눌렸을 때 - // 1. 로컬 알림 만들기 - // 2. 서버에 입력받은 거 전송하기 - // TODO: 지훈이가 만들어준 로컬 알림 만드는 객체에 요청하기 <- 객체가 필요 <- 생성자 주입 - /// 생성자 또는 메서드 전달인자 - promiseID, promiseTime, readyTime, moveTime + calculateTimes() + + // 로컬 알림 설정 + scheduleLocalNotification() Task { let model = MyPromiseReadyInfoModel( @@ -118,4 +119,82 @@ final class SetReadyInfoViewModel { } } } + + private func scheduleLocalNotification() { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + dateFormatter.locale = Locale(identifier: "ko_KR") + dateFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") + + guard let promiseDate = dateFormatter.date(from: self.promiseTime) else { + print("Invalid date format: \(self.promiseTime)") + return + } + + let totalPrepTime = TimeInterval((self.readyTime + self.moveTime) * 60) + + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "HH:mm:ss" + timeFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") + + print("약속 시간: \(timeFormatter.string(from: promiseDate))") + print("준비 시간: \(self.readyTime) 분") + print("이동 시간: \(self.moveTime) 분") + print("총 준비 시간: \(totalPrepTime / 60) 분") + + let readyStartTime = promiseDate.addingTimeInterval(-TimeInterval(self.readyTime + self.moveTime) * 60) + let moveStartTime = promiseDate.addingTimeInterval(-TimeInterval(self.moveTime) * 60) + + print("준비 시작 시간: \(timeFormatter.string(from: readyStartTime))") + print("이동 시작 시간: \(timeFormatter.string(from: moveStartTime))") + + self.notificationManager.requestAuthorization { [weak self] granted in + guard let self = self else { return } + if granted { + UNUserNotificationCenter.current().getNotificationSettings { settings in + print("현재 알림 설정: \(settings)") + } + + self.notificationManager.removeAllPendingNotifications() + + self.notificationManager.scheduleNotification( + title: "준비 시작", + body: "\(self.promiseName) 약속 준비를 시작할 시간입니다!", + triggerDate: readyStartTime, + identifier: "readyStart_\(self.promiseID)" + ) { error in + if let error = error { + print("준비 시작 알림 설정 실패: \(error)") + } else { + print("준비 시작 알림이 \(timeFormatter.string(from: readyStartTime))에 설정되었습니다.") + } + } + + self.notificationManager.scheduleNotification( + title: "이동 시작", + body: "\(self.promiseName) 약속 장소로 이동할 시간입니다!", + triggerDate: moveStartTime, + identifier: "moveStart_\(self.promiseID)" + ) { error in + if let error = error { + print("이동 시작 알림 설정 실패: \(error)") + } else { + print("이동 시작 알림이 \(timeFormatter.string(from: moveStartTime))에 설정되었습니다.") + } + } + + self.notificationManager.getPendingNotifications { requests in + print("예정된 알림 수: \(requests.count)") + for request in requests { + if let trigger = request.trigger as? UNCalendarNotificationTrigger, + let nextTriggerDate = trigger.nextTriggerDate() { + print("알림 ID: \(request.identifier), 예정 시간: \(timeFormatter.string(from: nextTriggerDate))") + } + } + } + } else { + print("알림 권한이 허용되지 않았습니다.") + } + } + } }