Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions Projects/Presentation/Timer/Sources/Container/TimerContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
//
// TimerContainer.swift
// Timer
//
// Created by 홍석현 on 2025-11-10
// Copyright © 2025 DDD , Ltd., All rights reserved.
//

import Foundation
import Combine
import ActivityKit
import UserNotifications
import AVFoundation

// MARK: - Timer Container (ViewModel)
public final class TimerContainer: ObservableObject {
// MARK: - Published State
@Published public private(set) var state: TimerState

// MARK: - Private Properties
private let model: TimerModelProtocol
private let notificationService: TimerNotificationServiceProtocol
private var cancellables = Set<AnyCancellable>()
private var timerCancellable: AnyCancellable?
private var currentActivity: Activity<TimerActivityAttributes>?
private var audioPlayer: AVAudioPlayer?

// MARK: - Published Alert State
@Published public var showCompletionAlert: Bool = false

// MARK: - Initialization
public init(
model: TimerModelProtocol = TimerModel(),
notificationService: TimerNotificationServiceProtocol = TimerNotificationService(),
initialState: TimerState = TimerState()
) {
self.model = model
self.notificationService = notificationService
self.state = initialState

// 알림 권한 요청
Task {
await notificationService.requestAuthorization()
}
}

// MARK: - Intent Processing
public func send(_ intent: TimerIntent) {
let (newState, sideEffect) = model.reduce(state: state, intent: intent)
state = newState

if let sideEffect = sideEffect {
handleSideEffect(sideEffect)
}
}

// MARK: - Side Effect Handling
private func handleSideEffect(_ sideEffect: TimerSideEffect) {
switch sideEffect {
case .startTimerTicking:
startTimer()
startLiveActivity()
scheduleNotification()

case .stopTimerTicking:
stopTimer()
updateLiveActivity()
cancelNotification()

case .playAlarm:
playAlarm()
endLiveActivity()
cancelNotification()

case .showCompletionAlert:
displayCompletionAlert()
}
}

// MARK: - Timer Management
private var tickCount: Int = 0

private func startTimer() {
stopTimer() // 기존 타이머 정리
tickCount = 0

// 애니메이션과 동기화를 위해 0.3초 지연
Task { @MainActor in
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3초

self.timerCancellable = Timer.publish(every: 0.1, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
guard let self = self else { return }

// endDate 기반으로 남은 시간 계산 (백그라운드에서도 정확함)
if let endDate = self.state.endDate {
let remaining = endDate.timeIntervalSinceNow

if remaining > 0 {
self.send(.updateRemainingTime(remaining))

// 1초마다 Live Activity 업데이트 (10틱 = 1초)
self.tickCount += 1
if self.tickCount >= 10 {
self.updateLiveActivity()
self.tickCount = 0
}
} else {
self.stopTimer()
self.send(.timerCompleted)
}
}
}
}
}

private func stopTimer() {
timerCancellable?.cancel()
timerCancellable = nil
}

// MARK: - Alarm & Notifications
private func playAlarm() {
// 시스템 사운드 재생 (진동 포함)
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
AudioServicesPlaySystemSound(1005) // 알림 사운드

print("🔔 타이머 완료! 알람 울림")

// 완료 알림도 함께 표시
displayCompletionAlert()
}

private func displayCompletionAlert() {
Task { @MainActor in
self.showCompletionAlert = true
}
print("✅ 타이머 완료 알림")
}

// MARK: - Notification Management
private func scheduleNotification() {
guard let endDate = state.endDate else { return }
notificationService.scheduleNotification(endDate: endDate, totalTime: state.totalTime)
}

private func cancelNotification() {
notificationService.cancelNotification()
}

// MARK: - Live Activity Management
private func startLiveActivity() {
let authInfo = ActivityAuthorizationInfo()
guard authInfo.areActivitiesEnabled else {
print("❌ Live Activities가 비활성화되어 있습니다.")
return
}

if currentActivity != nil {
updateLiveActivity()
return
}

Task {
let contentState = TimerActivityAttributes.ContentState(
totalTime: state.totalTime,
remainingTime: state.remainingTime,
endDate: state.endDate,
isRunning: state.isRunning,
isPaused: state.isPaused
)

do {
let activity = try Activity<TimerActivityAttributes>.request(
attributes: TimerActivityAttributes(),
content: .init(state: contentState, staleDate: nil)
)
currentActivity = activity
print("✅ Timer Live Activity 시작: \(activity.id)")
} catch {
print("❌ Timer Live Activity 시작 실패: \(error)")
}
}
}

private func updateLiveActivity() {
guard let activity = currentActivity else { return }

Task {
let contentState = TimerActivityAttributes.ContentState(
totalTime: state.totalTime,
remainingTime: state.remainingTime,
endDate: state.endDate,
isRunning: state.isRunning,
isPaused: state.isPaused
)

let staleDate = Date().addingTimeInterval(2)
await activity.update(.init(state: contentState, staleDate: staleDate))
}
}

private func endLiveActivity() {
guard let activity = currentActivity else { return }

Task {
let finalState = TimerActivityAttributes.ContentState(
totalTime: state.totalTime,
remainingTime: 0,
endDate: nil,
isRunning: false,
isPaused: false
)

await activity.end(
.init(state: finalState, staleDate: nil),
dismissalPolicy: .default
)
currentActivity = nil
print("✅ Timer Live Activity 종료")
}
}

// MARK: - Cleanup
deinit {
stopTimer()
cancelNotification()
Task { @MainActor [weak self] in
if let activity = self?.currentActivity {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ public enum TimerIntent {
case resumeTimer
case cancelTimer
case timerTick
case updateRemainingTime(TimeInterval)
case timerCompleted
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,24 @@ public final class TimerModel: TimerModelProtocol {
newState.remainingTime = TimeInterval(totalSeconds)
newState.progress = 1.0
newState.timerStatus = .running
newState.endDate = Date().addingTimeInterval(TimeInterval(totalSeconds))
sideEffect = .startTimerTicking

case .pauseTimer:
newState.timerStatus = .paused
newState.endDate = nil
sideEffect = .stopTimerTicking

case .resumeTimer:
newState.timerStatus = .running
newState.endDate = Date().addingTimeInterval(state.remainingTime)
sideEffect = .startTimerTicking

case .cancelTimer:
newState.timerStatus = .idle
newState.remainingTime = 0
newState.progress = 1.0
newState.endDate = nil
sideEffect = .stopTimerTicking

case .timerTick:
Expand All @@ -62,10 +66,15 @@ public final class TimerModel: TimerModelProtocol {
newState.progress = state.totalTime > 0 ? newState.remainingTime / state.totalTime : 0
}

case .updateRemainingTime(let remaining):
newState.remainingTime = max(0, remaining)
newState.progress = state.totalTime > 0 ? newState.remainingTime / state.totalTime : 0

case .timerCompleted:
newState.timerStatus = .idle
newState.remainingTime = 0
newState.progress = 1.0
newState.endDate = nil
sideEffect = .playAlarm
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public struct TimerState: Equatable {
public var remainingTime: TimeInterval
public var totalTime: TimeInterval
public var progress: Double
public var endDate: Date?

public init(
selectedHours: Int = 0,
Expand All @@ -28,7 +29,8 @@ public struct TimerState: Equatable {
timerStatus: TimerStatus = .idle,
remainingTime: TimeInterval = 0,
totalTime: TimeInterval = 0,
progress: Double = 1.0
progress: Double = 1.0,
endDate: Date? = nil
) {
self.selectedHours = selectedHours
self.selectedMinutes = selectedMinutes
Expand All @@ -37,6 +39,7 @@ public struct TimerState: Equatable {
self.remainingTime = remainingTime
self.totalTime = totalTime
self.progress = progress
self.endDate = endDate
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// TimerNotificationService.swift
// Timer
//
// Created by 홍석현 on 11/11/25.
//

import Foundation
import UserNotifications

public protocol TimerNotificationServiceProtocol {
func requestAuthorization() async -> Bool
func scheduleNotification(endDate: Date, totalTime: TimeInterval)
func cancelNotification()
}

public final class TimerNotificationService: TimerNotificationServiceProtocol {
private let notificationCenter = UNUserNotificationCenter.current()
private let notificationIdentifier = "com.wakeyalarm.timer.completion"

public init() {}

// MARK: - Authorization
public func requestAuthorization() async -> Bool {
do {
let granted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
print("✅ 타이머 알림 권한 허용됨")
} else {
print("🚫 타이머 알림 권한 거부됨")
}
return granted
} catch {
print("❌ 타이머 알림 권한 요청 실패: \(error.localizedDescription)")
return false
}
}

// MARK: - Schedule
public func scheduleNotification(endDate: Date, totalTime: TimeInterval) {
let content = UNMutableNotificationContent()
content.title = "⏰ 타이머 완료"
content.body = formatTimeForMessage(totalTime) + " 타이머가 완료되었습니다"
content.sound = .default
content.categoryIdentifier = "TIMER_COMPLETION"

let timeInterval = endDate.timeIntervalSinceNow

// 최소 1초 이상이어야 알림이 예약됨
guard timeInterval > 1 else {
print("⚠️ 타이머 종료 시간이 너무 짧아 알림을 예약할 수 없습니다")
return
}

let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false)
let request = UNNotificationRequest(identifier: notificationIdentifier, content: content, trigger: trigger)

notificationCenter.add(request) { error in
if let error = error {
print("❌ 타이머 알림 예약 실패: \(error.localizedDescription)")
} else {
print("✅ 타이머 알림 예약 완료: \(self.formatTimeForMessage(totalTime))")
}
}
}

// MARK: - Cancel
public func cancelNotification() {
notificationCenter.removePendingNotificationRequests(withIdentifiers: [notificationIdentifier])
notificationCenter.removeDeliveredNotifications(withIdentifiers: [notificationIdentifier])
print("🗑️ 타이머 알림 취소됨")
}

// MARK: - Helper
private func formatTimeForMessage(_ timeInterval: TimeInterval) -> String {
let hours = Int(timeInterval) / 3600
let minutes = (Int(timeInterval) % 3600) / 60
let seconds = Int(timeInterval) % 60

if hours > 0 {
return String(format: "%d시간 %d분 %d초", hours, minutes, seconds)
} else if minutes > 0 {
return String(format: "%d분 %d초", minutes, seconds)
} else {
return String(format: "%d초", seconds)
}
}
}
Loading
Loading