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
8 changes: 8 additions & 0 deletions Atcha-iOS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@
6DC3BF5E2E07123F00831470 /* IntroCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF5D2E07123F00831470 /* IntroCell.swift */; };
6DC3BF602E071F0900831470 /* LoginUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF5F2E071F0900831470 /* LoginUseCase.swift */; };
6DC3BF682E0721F300831470 /* LoginDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF672E0721F300831470 /* LoginDTO.swift */; };
6DC617A72F60EB1A002DD641 /* LocationSmoother.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */; };
6DC617A92F610238002DD641 /* HomeArrivalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC617A82F610238002DD641 /* HomeArrivalManager.swift */; };
6DD632B12E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B02E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift */; };
6DD632B62E52E23A00C6A66E /* ProximityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B52E52E23A00C6A66E /* ProximityManager.swift */; };
6DD632B92E52E8E300C6A66E /* ProximityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B82E52E8E300C6A66E /* ProximityViewController.swift */; };
Expand Down Expand Up @@ -472,6 +474,8 @@
6DC3BF5D2E07123F00831470 /* IntroCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroCell.swift; sourceTree = "<group>"; };
6DC3BF5F2E071F0900831470 /* LoginUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUseCase.swift; sourceTree = "<group>"; };
6DC3BF672E0721F300831470 /* LoginDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginDTO.swift; sourceTree = "<group>"; };
6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSmoother.swift; sourceTree = "<group>"; };
6DC617A82F610238002DD641 /* HomeArrivalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeArrivalManager.swift; sourceTree = "<group>"; };
6DD632B02E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckServiceRegionRequest.swift; sourceTree = "<group>"; };
6DD632B52E52E23A00C6A66E /* ProximityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityManager.swift; sourceTree = "<group>"; };
6DD632B82E52E8E300C6A66E /* ProximityViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1181,6 +1185,8 @@
6DADA5952EA09B9500CA9BE2 /* Amplitude */,
B61C448D2E3F57B600285A4B /* AlarmManager.swift */,
6DD632B72E52E8D000C6A66E /* Proximity */,
6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */,
6DC617A82F610238002DD641 /* HomeArrivalManager.swift */,
);
path = Manager;
sourceTree = "<group>";
Expand Down Expand Up @@ -2199,6 +2205,7 @@
B664018B2E2277A900A397AE /* PushAlarmOption.swift in Sources */,
6D26E0072F3C197F005097A4 /* SubwayInfoRepository.swift in Sources */,
B65C13082E057C590016D2F0 /* APIService.swift in Sources */,
6DC617A92F610238002DD641 /* HomeArrivalManager.swift in Sources */,
6D6879D02E4211B800E59C55 /* HomePatchRequest.swift in Sources */,
6D9283742E3AFF6A0090889B /* BusRouteCell.swift in Sources */,
6D73EB5F2E16120200F8DF8B /* CourseSearchViewModel.swift in Sources */,
Expand Down Expand Up @@ -2281,6 +2288,7 @@
6DADA5972EA09BA400CA9BE2 /* AmplitudeManager.swift in Sources */,
6DD632C22E544DB500C6A66E /* PushAlarmBottomView.swift in Sources */,
6D5E03D02E28853E0065AFBE /* CourseSearchResponse.swift in Sources */,
6DC617A72F60EB1A002DD641 /* LocationSmoother.swift in Sources */,
6D91A8E62E29F5BC0081BAFC /* CourseSettingViewModel.swift in Sources */,
6D26E0002F3C17C3005097A4 /* SubwayRealTimeInfoRequest.swift in Sources */,
B673C4912E0424FD00EE4AD0 /* SplashViewModel.swift in Sources */,
Expand Down
48 changes: 46 additions & 2 deletions Atcha-iOS/Core/Manager/AlarmManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ final class AlarmManager {
private var shouldKeepBackgroundAudio = false
private var isPreviewing = false
private var hapticEngine: CHHapticEngine?
private var autoStopWorkItem: DispatchWorkItem?

// MARK: - Init
private init() {
Expand Down Expand Up @@ -71,6 +72,8 @@ final class AlarmManager {
/// 서버에서 받은 출발 시각 기준으로 1분 전에 반복 푸시/사운드/진동을 시작
func startAlarm(title: String, body: String) {
// 기존 알람 상태만 정리 (silent는 유지 or 다시 켜기)
autoStopWorkItem?.cancel()
autoStopWorkItem = nil
stopAlarm(keepSilent: true)
ensureBackgroundSilentRunning()
applySavedVolumeForAlarmStart()
Expand Down Expand Up @@ -121,6 +124,7 @@ final class AlarmManager {
}

print("알람 종료 (keepSilent = \(keepSilent))")
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [AlarmNotificationID.autoStopInfo])
}

func alarmInit() {
Expand Down Expand Up @@ -220,13 +224,19 @@ extension AlarmManager {

self.sendImmediateLocalPush(title: title, body: body)
}

scheduleAutoStop()
}

func sendImmediateLocalPush(title: String, body: String) {
func sendImmediateLocalPush(title: String, body: String, playSound: Bool = false) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = nil // 사운드는 직접 재생 중
if playSound {
content.sound = .default
} else {
content.sound = nil
}

let request = UNNotificationRequest(
identifier: UUID().uuidString,
Expand Down Expand Up @@ -593,3 +603,37 @@ extension AlarmManager {
print("미리듣기 종료 (keep=\(shouldKeepBackgroundAudio))")
}
}

extension AlarmManager {
private func scheduleAutoStop() {
autoStopWorkItem?.cancel()

let content = UNMutableNotificationContent()
content.title = "출발 알람이 자동 종료되었어요"
content.body = "클릭해서 경로 재탐색하기"
content.sound = .default

let request = UNNotificationRequest(
identifier: AlarmNotificationID.autoStopInfo,
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 120.0, repeats: false)
)
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)

// 2. 앱 내부의 정지 로직 및 팝업 신호 (포그라운드일 때 즉시, 백그라운드면 켜질 때 실행됨)
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }

// 음악/진동 정지
self.stopAlarm(keepSilent: false) // 아예 무음까지 끄기

UserDefaults.standard.set(true, forKey: "isAlarmTimedOut")

// 메인 뷰에 타임아웃 팝업 띄우라고 신호
NotificationCenter.default.post(name: NSNotification.Name("alarmDidTimeout"), object: nil)
}

autoStopWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 120.0, execute: workItem)
}
}
55 changes: 55 additions & 0 deletions Atcha-iOS/Core/Manager/HomeArrivalManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// HomeArrivalManager.swift
// Atcha-iOS
//
// Created by wodnd on 3/11/26.
//

import Foundation
import CoreLocation
import UserNotifications
import UIKit

final class HomeArrivalManager {
static let shared = HomeArrivalManager()
private init() {}

private var isArrivalSignalSent = false

func checkHomeArrival(currentCoord: CLLocationCoordinate2D) {
let wrapper = UserDefaultsWrapper.shared

guard wrapper.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) == true else {
isArrivalSignalSent = false
return
}

guard !isArrivalSignalSent else { return }

let homeLat = wrapper.double(forKey: UserDefaultsWrapper.Key.homeLat.rawValue) ?? 0.0
let homeLon = wrapper.double(forKey: UserDefaultsWrapper.Key.homeLon.rawValue) ?? 0.0

guard homeLat != 0 && homeLon != 0 else { return }

let homeLoc = CLLocation(latitude: homeLat, longitude: homeLon)
let currentLoc = CLLocation(latitude: currentCoord.latitude, longitude: currentCoord.longitude)

let distance = currentLoc.distance(from: homeLoc)

if distance <= 50 {
isArrivalSignalSent = true

AlarmManager.shared.sendImmediateLocalPush(
title: "막차 안내 종료",
body: "목적지 부근에 도착했어요",
playSound: true
)

NotificationCenter.default.post(name: NSNotification.Name("userArrivedHome"), object: nil)
}
}

func reset() {
isArrivalSignalSent = false
}
}
67 changes: 67 additions & 0 deletions Atcha-iOS/Core/Manager/LocationSmoother.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// LocationSmoother.swift
// Atcha-iOS
//
// Created by wodnd on 3/11/26.
//

import Foundation
import CoreLocation
import MapKit

final class LocationSmoother {
private var buffer: [CLLocationCoordinate2D] = []
private let bufferLimit: Int

init(limit: Int = 5) {
self.bufferLimit = limit
}

func smooth(_ next: CLLocationCoordinate2D) -> CLLocationCoordinate2D {
buffer.append(next)
if buffer.count > bufferLimit { buffer.removeFirst() }

let avgLat = buffer.map { $0.latitude }.reduce(0, +) / Double(buffer.count)
let avgLon = buffer.map { $0.longitude }.reduce(0, +) / Double(buffer.count)

return CLLocationCoordinate2D(latitude: avgLat, longitude: avgLon)
}
}

extension LocationSmoother {
/// 좌표를 경로선(Polyline) 위 가장 가까운 점으로 고정합니다.
func snap(current: CLLocationCoordinate2D, polyline: [CLLocationCoordinate2D], threshold: Double = 150) -> CLLocationCoordinate2D {
guard polyline.count >= 2 else { return current }

let p = MKMapPoint(current)
var minDistance = Double.greatestFiniteMagnitude
var closestPoint = p

for i in 0..<(polyline.count - 1) {
let a = MKMapPoint(polyline[i])
let b = MKMapPoint(polyline[i + 1])

let projected = closestPointOnSegment(p, a, b)
let distance = projected.distance(to: p)

if distance < minDistance {
minDistance = distance
closestPoint = projected
}
}

// 임계값(150m) 보다 멀어지면 사용자가 경로를 이탈한 것으로 간주하여 원본 좌표 반환
return minDistance < threshold ? closestPoint.coordinate : current
}

private func closestPointOnSegment(_ p: MKMapPoint, _ a: MKMapPoint, _ b: MKMapPoint) -> MKMapPoint {
let dx = b.x - a.x
let dy = b.y - a.y
if dx == 0 && dy == 0 { return a }

let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy)
if t < 0 { return a }
if t > 1 { return b }
return MKMapPoint(x: a.x + t * dx, y: a.y + t * dy)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ extension CourseSearchViewController {
let popupVC = AtchaPopupViewController(viewModel: popupVM)

popupVC.confirmButton.addAction(UIAction { [weak popupVC] _ in
popupVC?.dismiss(animated: true)
popupVC?.dismiss(animated: false)

self.viewModel.alarmRegister(alarmRequest)
self.viewModel.getAlarmTapped?(alarmTapped.0, alarmTapped.1)
Expand Down Expand Up @@ -392,7 +392,7 @@ extension CourseSearchViewController {
let popupVC = AtchaPopupViewController(viewModel: popupVM)

popupVC.confirmButton.addAction(UIAction { [weak popupVC] _ in
popupVC?.dismiss(animated: true)
popupVC?.dismiss(animated: false)

self.viewModel.alarmRegister(alarmRequest)
self.viewModel.getAlarmTapped?(alarmTapped.0, alarmTapped.1)
Expand Down
Loading