From 42b08fb45fa6588a002995fe5a8344112f160d68 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:03:54 +0900 Subject: [PATCH 01/23] =?UTF-8?q?FEAT:=20RouteStep=20=EB=85=B8=EC=84=A0?= =?UTF-8?q?=EB=AA=85=C2=B7=EB=85=B8=EC=84=A0=20=EC=83=89=C2=B7=EC=A0=95?= =?UTF-8?q?=EA=B1=B0=EC=9E=A5=20=EC=88=98=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Domain/Entity/RouteStep.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/WhereAreYou/WhereAreYou/Domain/Entity/RouteStep.swift b/WhereAreYou/WhereAreYou/Domain/Entity/RouteStep.swift index fec58c3..1802171 100644 --- a/WhereAreYou/WhereAreYou/Domain/Entity/RouteStep.swift +++ b/WhereAreYou/WhereAreYou/Domain/Entity/RouteStep.swift @@ -13,6 +13,12 @@ struct RouteStep { let distance: Double let transportType: TransportType let path: [Coordinate] + /// 대중교통 노선명 (예: "2호선", "146") — 도보·자동차는 nil + let lineName: String? + /// 노선 대표 색 hex (예: "#00A84D") — 도보·자동차는 nil + let lineColorHex: String? + /// 탑승역부터 하차역까지 정거장 수 — 도보·자동차는 nil + let stopCount: Int? init( departurePoint: Place, @@ -20,7 +26,10 @@ struct RouteStep { estimatedTime: Double, distance: Double, transportType: TransportType, - path: [Coordinate] = [] + path: [Coordinate] = [], + lineName: String? = nil, + lineColorHex: String? = nil, + stopCount: Int? = nil ) { self.departurePoint = departurePoint self.destination = destination @@ -28,6 +37,9 @@ struct RouteStep { self.distance = distance self.transportType = transportType self.path = path + self.lineName = lineName + self.lineColorHex = lineColorHex + self.stopCount = stopCount } } From eecf091fc635d531b6e1f2fd29477c755ccfa096 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:03:54 +0900 Subject: [PATCH 02/23] =?UTF-8?q?FEAT:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=95=A1=ED=8B=B0=EB=B9=84=ED=8B=B0=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=EC=9A=A9=20Mock=20=EB=82=B4=20=EA=B2=BD=EB=A1=9C=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MockAppointmentDetailRepository.swift | 198 +++++++++++++----- 1 file changed, 147 insertions(+), 51 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift b/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift index c8af7eb..42bd941 100644 --- a/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift @@ -34,6 +34,11 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { } private static let currentUserID = "user_me" + + /// 내 경로 시나리오 — DEBUG 라이브 액티비티 시작 메뉴에서 바꿔 가며 확인한다 + static var myRouteScenario: MockMyRouteScenario = .subwayTransfer + /// true면 약속 시각을 5분 전으로 당긴다 — 약속 시간이 지난 상태의 라이브 액티비티 확인용 + static var startsAfterAppointmentTime = false private static let mockCode = "MOCK1234" private static func makeMockAppointment(appointmentID: String) -> Appointment { @@ -46,6 +51,8 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { ) let now = Date() + // 약속 시각과 내 경로만 당긴다. 다른 참여자 경로는 지도 표시용이라 그대로 둔다 + let myTimelineBase = startsAfterAppointmentTime ? now.addingTimeInterval(-30 * 60) : now let users: [User] = [ User( @@ -105,10 +112,8 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { ] let routes: [String: Route] = [ - currentUserID: makeMultiStepRoute( - to: place, departureTime: now.addingTimeInterval(-45 * 60), - arrivalTime: now.addingTimeInterval(13 * 60), - start: Coordinate(latitude: 37.4930, longitude: 127.0180) + currentUserID: makeMyRoute( + to: place, departureTime: myTimelineBase.addingTimeInterval(1 * 60) ), "user_minji": makeRoute( to: place, departureTime: now.addingTimeInterval(-40 * 60), @@ -146,7 +151,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { id: appointmentID, code: Self.mockCode, name: "고기굽는방앗간 이수역점", - dateTime: now.addingTimeInterval(13 * 60), + dateTime: myTimelineBase.addingTimeInterval(25 * 60), place: place, participants: users, routes: routes, @@ -201,59 +206,150 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { } } - /// 나의 경로를 걷기 → 자동차 → 대중교통 → 걷기 4단계로 구성한 mock - /// stepsStack 다중 스텝 렌더링 확인용 - private static func makeMultiStepRoute( - to destination: Place, - departureTime: Date, - arrivalTime: Date, - start: Coordinate - ) -> Route { - let totalMinutes = arrivalTime.timeIntervalSince(departureTime) / 60 - let ratios: [(TransportType, Double)] = [ - (.walk, 0.1), - (.car, 0.35), - (.transit, 0.4), - (.walk, 0.15), - ] - - let waypointRatios: [Double] = [0] + ratios.reduce(into: []) { result, item in - result.append((result.last ?? 0) + item.1) - } - - let waypoints = waypointRatios.map { ratio in - Coordinate( - latitude: start.latitude + (destination.coordinate.latitude - start.latitude) * ratio, - longitude: start.longitude + (destination.coordinate.longitude - start.longitude) * ratio - ) - } - - let places = waypoints.enumerated().map { index, coordinate in - index == waypoints.count - 1 - ? destination - : Place(id: "waypoint_\(index)_\(UUID().uuidString)", name: "경유지", address: "", coordinate: coordinate, type: .other) - } - - let steps = ratios.enumerated().map { index, item -> RouteStep in - let (transportType, ratio) = item - let from = places[index] - let to = places[index + 1] - return RouteStep( - departurePoint: from, - destination: to, - estimatedTime: totalMinutes * ratio, - distance: from.coordinate.distance(to: to.coordinate), - transportType: transportType, - path: interpolatedPath(from: from.coordinate, to: to.coordinate, segments: 3) - ) + /// 모든 시나리오가 1분 뒤 출발, 23분 이동, 약속 1분 전 도착으로 맞춰져 있다 + private static func makeMyRoute(to destination: Place, departureTime: Date) -> Route { + let steps: [RouteStep] + switch myRouteScenario { + case .subwayTransfer: + steps = makeSubwayTransferSteps(to: destination) + case .busAndSubway: + steps = makeBusAndSubwaySteps(to: destination) + case .car: + steps = makeCarSteps(to: destination) } + let totalMinutes = steps.reduce(0) { $0 + $1.estimatedTime } return Route( departureTime: departureTime, - arrivalTime: arrivalTime, + arrivalTime: departureTime.addingTimeInterval(totalMinutes * 60), step: steps, totalDistance: steps.reduce(0) { $0 + $1.distance } ) } + private static let mockHome = Place( + id: "mock_home", name: "출발지", address: "", + coordinate: Coordinate(latitude: 37.4880, longitude: 126.9870), type: .other + ) + + /// 도보 → 4호선 → 환승 도보 → 2호선 → 도보 + /// 단계: 출발 → 이수역 → 사당역 → 강남역 → 도착 + private static func makeSubwayTransferSteps(to destination: Place) -> [RouteStep] { + let isu = Place( + id: "mock_station_isu", name: "이수역", address: "", + coordinate: Coordinate(latitude: 37.4863, longitude: 126.9819), type: .subway + ) + let sadang4 = Place( + id: "mock_station_sadang_4", name: "사당역", address: "", + coordinate: Coordinate(latitude: 37.4766, longitude: 126.9816), type: .subway + ) + let sadang2 = Place( + id: "mock_station_sadang_2", name: "사당역", address: "", + coordinate: Coordinate(latitude: 37.4765, longitude: 126.9814), type: .subway + ) + let gangnam = Place( + id: "mock_station_gangnam", name: "강남역", address: "", + coordinate: Coordinate(latitude: 37.4980, longitude: 127.0276), type: .subway + ) + + return [ + makeStep(from: mockHome, to: isu, minutes: 6, transportType: .walk), + makeStep(from: isu, to: sadang4, minutes: 2, transportType: .transit, + lineName: "4호선", lineColorHex: "#00A5DE", stopCount: 1), + makeStep(from: sadang4, to: sadang2, minutes: 3, transportType: .walk), + makeStep(from: sadang2, to: gangnam, minutes: 8, transportType: .transit, + lineName: "2호선", lineColorHex: "#00A84D", stopCount: 4), + makeStep(from: gangnam, to: destination, minutes: 4, transportType: .walk), + ] + } + + /// 도보 → 간선버스 740 → 환승 도보 → 2호선 → 도보 + /// 단계: 출발 → 방배경찰서 → 교대역 → 강남역 → 도착 + private static func makeBusAndSubwaySteps(to destination: Place) -> [RouteStep] { + let busStop = Place( + id: "mock_bus_stop_bangbae", name: "방배경찰서", address: "", + coordinate: Coordinate(latitude: 37.4895, longitude: 126.9935), type: .station + ) + let busStopGyodae = Place( + id: "mock_bus_stop_gyodae", name: "교대역", address: "", + coordinate: Coordinate(latitude: 37.4930, longitude: 127.0130), type: .station + ) + let gyodae = Place( + id: "mock_station_gyodae", name: "교대역", address: "", + coordinate: Coordinate(latitude: 37.4934, longitude: 127.0142), type: .subway + ) + let gangnam = Place( + id: "mock_station_gangnam", name: "강남역", address: "", + coordinate: Coordinate(latitude: 37.4980, longitude: 127.0276), type: .subway + ) + + return [ + makeStep(from: mockHome, to: busStop, minutes: 3, transportType: .walk), + makeStep(from: busStop, to: busStopGyodae, minutes: 12, transportType: .transit, + lineName: "740", lineColorHex: "#3D5BAB", stopCount: 5), + makeStep(from: busStopGyodae, to: gyodae, minutes: 2, transportType: .walk), + makeStep(from: gyodae, to: gangnam, minutes: 2, transportType: .transit, + lineName: "2호선", lineColorHex: "#00A84D", stopCount: 1), + makeStep(from: gangnam, to: destination, minutes: 4, transportType: .walk), + ] + } + + /// 도보 → 자차 → 도보 + /// 단계: 출발 → 도착 (탑승역이 없으므로 중간 단계 없음) + private static func makeCarSteps(to destination: Place) -> [RouteStep] { + let homeParking = Place( + id: "mock_parking_home", name: "집 주차장", address: "", + coordinate: Coordinate(latitude: 37.4884, longitude: 126.9876), type: .other + ) + let gangnamParking = Place( + id: "mock_parking_gangnam", name: "강남역 공영주차장", address: "", + coordinate: Coordinate(latitude: 37.4972, longitude: 127.0262), type: .other + ) + + return [ + makeStep(from: mockHome, to: homeParking, minutes: 2, transportType: .walk), + makeStep(from: homeParking, to: gangnamParking, minutes: 17, transportType: .car), + makeStep(from: gangnamParking, to: destination, minutes: 4, transportType: .walk), + ] + } + + private static func makeStep( + from: Place, + to: Place, + minutes: Double, + transportType: TransportType, + lineName: String? = nil, + lineColorHex: String? = nil, + stopCount: Int? = nil + ) -> RouteStep { + RouteStep( + departurePoint: from, + destination: to, + estimatedTime: minutes, + distance: from.coordinate.distance(to: to.coordinate), + transportType: transportType, + path: interpolatedPath(from: from.coordinate, to: to.coordinate, segments: 3), + lineName: lineName, + lineColorHex: lineColorHex, + stopCount: stopCount + ) + } + +} + +/// MockAppointmentDetailRepository가 돌려주는 내 경로 종류 +enum MockMyRouteScenario: CaseIterable { + + case subwayTransfer + case busAndSubway + case car + + var title: String { + switch self { + case .subwayTransfer: return "지하철 환승" + case .busAndSubway: return "버스 + 지하철" + case .car: return "자차" + } + } + } From 6d784b66fc66528cb8202197ccbae84896e7decc Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:03:54 +0900 Subject: [PATCH 03/23] =?UTF-8?q?FEAT:=20=EC=95=BD=EC=86=8D=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EC=A7=84=ED=96=89=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Entity/RouteCheckpoint.swift | 31 +++++++++++++ .../Domain/Entity/RouteProgressPlan.swift | 21 +++++++++ .../Domain/Entity/RouteProgressSegment.swift | 45 +++++++++++++++++++ .../Domain/Entity/RouteProgressStatus.swift | 14 ++++++ 4 files changed, 111 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Domain/Entity/RouteCheckpoint.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressPlan.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressSegment.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressStatus.swift diff --git a/WhereAreYou/WhereAreYou/Domain/Entity/RouteCheckpoint.swift b/WhereAreYou/WhereAreYou/Domain/Entity/RouteCheckpoint.swift new file mode 100644 index 0000000..31ece9a --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Entity/RouteCheckpoint.swift @@ -0,0 +1,31 @@ +// +// RouteCheckpoint.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +import Foundation + +/// 이동 중 "~했나요?"로 확인받는 지점 하나 — 종류, 장소, 예정 시각, 도달 후 진행 중인 구간 +struct RouteCheckpoint { + + /// 탑승 수단 기준 확인 지점 종류 + enum Kind { + /// 출발했나요? + case departure + /// 첫 탑승역에 도착했나요? + case boarding + /// 하차역에 도착했나요? (환승역 포함) + case alighting + /// 약속 장소에 도착했나요? + case arrival + } + + let kind: Kind + let placeName: String + let scheduledTime: Date + /// 이 지점에 도달한 뒤 사용자가 이동 중인 구간 인덱스. 도착이면 구간 수와 같다 + let segmentIndex: Int + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressPlan.swift b/WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressPlan.swift new file mode 100644 index 0000000..79e691a --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressPlan.swift @@ -0,0 +1,21 @@ +// +// RouteProgressPlan.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +import Foundation + +/// 약속 이동 한 번의 고정 정보 — 약속 요약, 구간 목록, 확인 지점 목록. 이동 중에는 바뀌지 않는다 +struct RouteProgressPlan { + + let appointmentID: String + let appointmentName: String + let placeName: String + let appointmentTime: Date + let participantCount: Int + let segments: [RouteProgressSegment] + let checkpoints: [RouteCheckpoint] + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressSegment.swift b/WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressSegment.swift new file mode 100644 index 0000000..6cdeb6d --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressSegment.swift @@ -0,0 +1,45 @@ +// +// RouteProgressSegment.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +/// 진행 바의 구간 하나 — RouteStep에서 좌표·Place를 뺀 표시용 요약. 라이브 액티비티 용량 제한 때문에 따로 둔다 +struct RouteProgressSegment { + + /// 대중교통 구간의 탈것 — 아이콘 구분용 + enum TransitVehicle { + case subway + case bus + } + + let transportType: TransportType + /// 대중교통이 아니면 nil + let transitVehicle: TransitVehicle? + let destinationName: String + let estimatedMinutes: Int + let lineName: String? + let lineColorHex: String? + let stopCount: Int? + +} + +extension RouteProgressSegment { + + /// 탑승 장소가 지하철역이면 지하철, 아니면 버스로 본다 + init(step: RouteStep) { + self.init( + transportType: step.transportType, + transitVehicle: step.transportType == .transit + ? (step.departurePoint.type == .subway ? .subway : .bus) + : nil, + destinationName: step.destination.name, + estimatedMinutes: Int(step.estimatedTime.rounded()), + lineName: step.lineName, + lineColorHex: step.lineColorHex, + stopCount: step.stopCount + ) + } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressStatus.swift b/WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressStatus.swift new file mode 100644 index 0000000..0ca722d --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Entity/RouteProgressStatus.swift @@ -0,0 +1,14 @@ +// +// RouteProgressStatus.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +/// 약속 이동 중 바뀌는 상태 — 확인한 지점 수, 도착 인원 +struct RouteProgressStatus { + + var reachedCheckpointCount: Int + var arrivedParticipantCount: Int + +} From fe9d6670354d7d7f07fafb09c39f9cef361b77cb Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:03:54 +0900 Subject: [PATCH 04/23] =?UTF-8?q?FEAT:=20=EC=95=BD=EC=86=8D=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=95=A1=ED=8B=B0?= =?UTF-8?q?=EB=B9=84=ED=8B=B0=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteLiveActivityRepository.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Domain/Repository/RouteLiveActivityRepository.swift diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/RouteLiveActivityRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/RouteLiveActivityRepository.swift new file mode 100644 index 0000000..892e8e2 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Repository/RouteLiveActivityRepository.swift @@ -0,0 +1,21 @@ +// +// RouteLiveActivityRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +import Foundation + +/// 진행 중인 약속 이동 라이브 액티비티 조회·갱신·종료. 한 번에 한 약속으로만 이동하므로 액티비티는 하나만 둔다. +/// 시작은 3단계에서 서버 push-to-start가 맡는다 — 임시 시작은 DebugLiveActivity의 RouteLiveActivityStarting +protocol RouteLiveActivityRepository { + + func current(appointmentID: String) -> (plan: RouteProgressPlan, status: RouteProgressStatus)? + + func update(appointmentID: String, status: RouteProgressStatus) async + + /// 최종 상태를 반영한 뒤 dismissalDate에 화면에서 내린다 + func end(appointmentID: String, status: RouteProgressStatus, dismissalDate: Date) async + +} From e1e0f51f210cf4350ebd926c40107babbbed07a5 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:03:54 +0900 Subject: [PATCH 05/23] =?UTF-8?q?FEAT:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=95=A1=ED=8B=B0=EB=B9=84=ED=8B=B0=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Service/RouteProgressService.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Domain/Service/RouteProgressService.swift diff --git a/WhereAreYou/WhereAreYou/Domain/Service/RouteProgressService.swift b/WhereAreYou/WhereAreYou/Domain/Service/RouteProgressService.swift new file mode 100644 index 0000000..98d2f72 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Service/RouteProgressService.swift @@ -0,0 +1,47 @@ +// +// RouteProgressService.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-27. +// + +import Foundation + +/// 약속 이동 진행 처리 — 라이브 액티비티 단계 버튼(LiveActivityIntent)이 화면 없이 부르므로 DI에 등록해 쓴다. +/// 다음 확인 지점으로 넘기고, 도착이면 종료한다. 약속 시간 전에 도착하면 약속 시간까지, 지나서 도착하면 1분 뒤까지 화면에 남긴다. +/// 도달 단계는 따로 저장하지 않고 액티비티 상태에만 둔다. 진행 중인 액티비티가 없거나 이미 도착했으면 아무것도 하지 않는다 +final class RouteProgressService { + + /// 약속 시간이 지나 도착했을 때 화면에 남겨 두는 시간 + static let dismissalDelayAfterLateArrival: TimeInterval = 60 + + private let liveActivityRepository: RouteLiveActivityRepository + + init(liveActivityRepository: RouteLiveActivityRepository) { + self.liveActivityRepository = liveActivityRepository + } + + func advance(appointmentID: String, now: Date = Date()) async { + guard let (plan, currentStatus) = liveActivityRepository.current(appointmentID: appointmentID) else { return } + + let reachedIndex = currentStatus.reachedCheckpointCount + guard plan.checkpoints.indices.contains(reachedIndex) else { return } + + // TODO: 채팅방에 도착 단계 메시지 전송 + 약속 지도에 내 위치 좌표 갱신 (약속·채팅 Firestore 전환 후) + let isArrival = plan.checkpoints[reachedIndex].kind == .arrival + var status = currentStatus + status.reachedCheckpointCount = reachedIndex + 1 + if isArrival { + // TODO: 도착 인원은 서버 데이터가 생긴 뒤 실데이터로 교체 + status.arrivedParticipantCount += 1 + await liveActivityRepository.end( + appointmentID: appointmentID, + status: status, + dismissalDate: max(plan.appointmentTime, now.addingTimeInterval(Self.dismissalDelayAfterLateArrival)) + ) + } else { + await liveActivityRepository.update(appointmentID: appointmentID, status: status) + } + } + +} From b99e5cdc0e38e4052c74be1e6383f2198d8e1e66 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:03:54 +0900 Subject: [PATCH 06/23] =?UTF-8?q?FEAT:=20=EC=95=BD=EC=86=8D=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=95=A1=ED=8B=B0?= =?UTF-8?q?=EB=B9=84=ED=8B=B0=20=EC=9C=84=EC=A0=AF=20=EC=9D=B5=EC=8A=A4?= =?UTF-8?q?=ED=85=90=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Shared/AdvanceRouteProgressIntent.swift | 35 ++ WhereAreYou/Shared/AppointmentDeepLink.swift | 55 ++++ .../AppointmentRouteActivityAttributes.swift | 65 ++++ .../WhereAreYou.xcodeproj/project.pbxproj | 191 +++++++++++ WhereAreYou/WhereAreYou/Core/Info.plist | 13 + .../AppointmentRouteDisplay.swift | 91 ++++++ .../AppointmentRouteLiveActivity.swift | 301 ++++++++++++++++++ WhereAreYou/WhereAreYouWidget/Info.plist | 11 + .../WhereAreYouWidget/RouteProgressBar.swift | 99 ++++++ .../WhereAreYouWidgetBundle.swift | 19 ++ .../WhereAreYouWidget/WidgetPalette.swift | 39 +++ 11 files changed, 919 insertions(+) create mode 100644 WhereAreYou/Shared/AdvanceRouteProgressIntent.swift create mode 100644 WhereAreYou/Shared/AppointmentDeepLink.swift create mode 100644 WhereAreYou/Shared/AppointmentRouteActivityAttributes.swift create mode 100644 WhereAreYou/WhereAreYouWidget/AppointmentRouteDisplay.swift create mode 100644 WhereAreYou/WhereAreYouWidget/AppointmentRouteLiveActivity.swift create mode 100644 WhereAreYou/WhereAreYouWidget/Info.plist create mode 100644 WhereAreYou/WhereAreYouWidget/RouteProgressBar.swift create mode 100644 WhereAreYou/WhereAreYouWidget/WhereAreYouWidgetBundle.swift create mode 100644 WhereAreYou/WhereAreYouWidget/WidgetPalette.swift diff --git a/WhereAreYou/Shared/AdvanceRouteProgressIntent.swift b/WhereAreYou/Shared/AdvanceRouteProgressIntent.swift new file mode 100644 index 0000000..df8eb5d --- /dev/null +++ b/WhereAreYou/Shared/AdvanceRouteProgressIntent.swift @@ -0,0 +1,35 @@ +// +// AdvanceRouteProgressIntent.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +import Foundation +import AppIntents + +/// 라이브 액티비티의 단계 버튼("출발했나요?" 등) — 누르면 액티비티를 다음 확인 지점으로 넘긴다. +/// LiveActivityIntent는 앱 프로세스에서 실행되므로 실제 처리는 앱 타깃에서만 컴파일된다 +struct AdvanceRouteProgressIntent: LiveActivityIntent { + + static var title: LocalizedStringResource = "이동 단계 진행" + static var isDiscoverable = false + + @Parameter(title: "약속 ID") + var appointmentID: String + + init() { } + + init(appointmentID: String) { + self.appointmentID = appointmentID + } + + func perform() async throws -> some IntentResult { + #if !WIDGET_EXTENSION + let service = await DIContainer.shared.resolve(RouteProgressService.self) + await service.advance(appointmentID: appointmentID) + #endif + return .result() + } + +} diff --git a/WhereAreYou/Shared/AppointmentDeepLink.swift b/WhereAreYou/Shared/AppointmentDeepLink.swift new file mode 100644 index 0000000..c442a38 --- /dev/null +++ b/WhereAreYou/Shared/AppointmentDeepLink.swift @@ -0,0 +1,55 @@ +// +// AppointmentDeepLink.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +import Foundation + +/// 라이브 액티비티에서 앱으로 들어오는 링크 — 위젯은 URL을 만들고 앱은 URL을 해석한다. +/// 형식: whereareyou://appointment/{약속 ID}/{route | share-location} +enum AppointmentDeepLink { + + /// 약속 지도 화면 열기 + case route(appointmentID: String) + /// 채팅방을 열고 내 위치를 바로 전송 + case shareLocation(appointmentID: String) + + static let scheme = "whereareyou" + private static let host = "appointment" + private static let routePath = "route" + private static let shareLocationPath = "share-location" + + var url: URL { + var components = URLComponents() + components.scheme = Self.scheme + components.host = Self.host + switch self { + case .route(let appointmentID): + components.path = "/\(appointmentID)/\(Self.routePath)" + case .shareLocation(let appointmentID): + components.path = "/\(appointmentID)/\(Self.shareLocationPath)" + } + // path에 들어가는 값은 약속 ID뿐이라 URL 생성이 실패하지 않는다 + return components.url! + } + + init?(url: URL) { + guard url.scheme == Self.scheme, url.host == Self.host else { return nil } + + let pathComponents = url.pathComponents.filter { $0 != "/" } + guard pathComponents.count == 2 else { return nil } + + let appointmentID = pathComponents[0] + switch pathComponents[1] { + case Self.routePath: + self = .route(appointmentID: appointmentID) + case Self.shareLocationPath: + self = .shareLocation(appointmentID: appointmentID) + default: + return nil + } + } + +} diff --git a/WhereAreYou/Shared/AppointmentRouteActivityAttributes.swift b/WhereAreYou/Shared/AppointmentRouteActivityAttributes.swift new file mode 100644 index 0000000..c2f5566 --- /dev/null +++ b/WhereAreYou/Shared/AppointmentRouteActivityAttributes.swift @@ -0,0 +1,65 @@ +// +// AppointmentRouteActivityAttributes.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +import Foundation +import ActivityKit + +/// 약속 이동 라이브 액티비티 데이터 — 앱과 위젯 익스텐션이 함께 쓰는 전송용 모델. +/// 속성과 상태를 합쳐 4KB를 넘을 수 없으므로 좌표·Place 없이 표시에 필요한 값만 담는다 +struct AppointmentRouteActivityAttributes: ActivityAttributes { + + /// 단계 버튼을 누를 때마다 바뀌는 값 + struct ContentState: Codable, Hashable { + var reachedCheckpointCount: Int + var arrivedParticipantCount: Int + } + + /// 진행 바의 구간 하나 + struct Segment: Codable, Hashable { + + /// 대중교통은 아이콘을 구분하려고 지하철·버스로 나눈다 + enum Transport: String, Codable, Hashable { + case walk + case car + case subway + case bus + } + + let transport: Transport + let destinationName: String + let estimatedMinutes: Int + let lineName: String? + let lineColorHex: String? + let stopCount: Int? + } + + /// "~했나요?"로 묻는 확인 지점 하나 + struct Checkpoint: Codable, Hashable { + + enum Kind: String, Codable, Hashable { + case departure + case boarding + case alighting + case arrival + } + + let kind: Kind + let placeName: String + let scheduledTime: Date + /// 이 지점에 도달한 뒤 이동 중인 구간 인덱스. 도착이면 구간 수와 같다 + let segmentIndex: Int + } + + let appointmentID: String + let appointmentName: String + let placeName: String + let appointmentTime: Date + let participantCount: Int + let segments: [Segment] + let checkpoints: [Checkpoint] + +} diff --git a/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj b/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj index 683bc89..b6007d7 100644 --- a/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj +++ b/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj @@ -13,11 +13,37 @@ 8C332F24303DEAC3008375EC /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 8C332F23303DEAC3008375EC /* FirebaseAuth */; }; 8C332F26303DED5E008375EC /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 8C332F25303DED5E008375EC /* FirebaseStorage */; }; 8CA7E506305E84B000A37397 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 8CA7E505305E84B000A37397 /* FirebaseMessaging */; }; + 8CF0A0022F90000100A1B2C3 /* WhereAreYouWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8CF0A0012F90000100A1B2C3 /* WhereAreYouWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; BCAC3F343017B45700EC9102 /* NMapsMap in Frameworks */ = {isa = PBXBuildFile; productRef = BCAC3F333017B45700EC9102 /* NMapsMap */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 8CF0A0042F90000100A1B2C3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8C44C1D42FFCF585006DA2FC /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8CF0A0092F90000100A1B2C3; + remoteInfo = WhereAreYouWidget; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 8CF0A0032F90000100A1B2C3 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 8CF0A0022F90000100A1B2C3 /* WhereAreYouWidget.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 8C44C1DC2FFCF585006DA2FC /* WhereAreYou.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WhereAreYou.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8CF0A0012F90000100A1B2C3 /* WhereAreYouWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WhereAreYouWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -28,6 +54,13 @@ ); target = 8C44C1DB2FFCF585006DA2FC /* WhereAreYou */; }; + 8CF0A0082F90000100A1B2C3 /* Exceptions for "WhereAreYouWidget" folder in "WhereAreYouWidget" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 8CF0A0092F90000100A1B2C3 /* WhereAreYouWidget */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -39,6 +72,19 @@ path = WhereAreYou; sourceTree = ""; }; + 8CF0A0062F90000100A1B2C3 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; + 8CF0A0072F90000100A1B2C3 /* WhereAreYouWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 8CF0A0082F90000100A1B2C3 /* Exceptions for "WhereAreYouWidget" folder in "WhereAreYouWidget" target */, + ); + path = WhereAreYouWidget; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -56,6 +102,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8CF0A00B2F90000100A1B2C3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -70,6 +123,8 @@ isa = PBXGroup; children = ( 8C44C1DE2FFCF585006DA2FC /* WhereAreYou */, + 8CF0A0062F90000100A1B2C3 /* Shared */, + 8CF0A0072F90000100A1B2C3 /* WhereAreYouWidget */, 8C332F1E303DEAB1008375EC /* Frameworks */, 8C44C1DD2FFCF585006DA2FC /* Products */, ); @@ -79,6 +134,7 @@ isa = PBXGroup; children = ( 8C44C1DC2FFCF585006DA2FC /* WhereAreYou.app */, + 8CF0A0012F90000100A1B2C3 /* WhereAreYouWidget.appex */, ); name = Products; sourceTree = ""; @@ -93,13 +149,16 @@ 8C44C1D82FFCF585006DA2FC /* Sources */, 8C44C1D92FFCF585006DA2FC /* Frameworks */, 8C44C1DA2FFCF585006DA2FC /* Resources */, + 8CF0A0032F90000100A1B2C3 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 8CF0A0052F90000100A1B2C3 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 8C44C1DE2FFCF585006DA2FC /* WhereAreYou */, + 8CF0A0062F90000100A1B2C3 /* Shared */, ); name = WhereAreYou; packageProductDependencies = ( @@ -115,6 +174,29 @@ productReference = 8C44C1DC2FFCF585006DA2FC /* WhereAreYou.app */; productType = "com.apple.product-type.application"; }; + 8CF0A0092F90000100A1B2C3 /* WhereAreYouWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8CF0A00D2F90000100A1B2C3 /* Build configuration list for PBXNativeTarget "WhereAreYouWidget" */; + buildPhases = ( + 8CF0A00A2F90000100A1B2C3 /* Sources */, + 8CF0A00B2F90000100A1B2C3 /* Frameworks */, + 8CF0A00C2F90000100A1B2C3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 8CF0A0062F90000100A1B2C3 /* Shared */, + 8CF0A0072F90000100A1B2C3 /* WhereAreYouWidget */, + ); + name = WhereAreYouWidget; + packageProductDependencies = ( + ); + productName = WhereAreYouWidget; + productReference = 8CF0A0012F90000100A1B2C3 /* WhereAreYouWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -128,6 +210,9 @@ 8C44C1DB2FFCF585006DA2FC = { CreatedOnToolsVersion = 26.1.1; }; + 8CF0A0092F90000100A1B2C3 = { + CreatedOnToolsVersion = 26.1.1; + }; }; }; buildConfigurationList = 8C44C1D72FFCF585006DA2FC /* Build configuration list for PBXProject "WhereAreYou" */; @@ -149,6 +234,7 @@ projectRoot = ""; targets = ( 8C44C1DB2FFCF585006DA2FC /* WhereAreYou */, + 8CF0A0092F90000100A1B2C3 /* WhereAreYouWidget */, ); }; /* End PBXProject section */ @@ -161,6 +247,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8CF0A00C2F90000100A1B2C3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -171,8 +264,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8CF0A00A2F90000100A1B2C3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 8CF0A0052F90000100A1B2C3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8CF0A0092F90000100A1B2C3 /* WhereAreYouWidget */; + targetProxy = 8CF0A0042F90000100A1B2C3 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 8C44C1F02FFCF586006DA2FC /* Debug */ = { isa = XCBuildConfiguration; @@ -389,6 +497,80 @@ }; name = Release; }; + 8CF0A00E2F90000100A1B2C3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 2ZF894QLGK; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhereAreYouWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WhereAreYouWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = kr.syl.WhereAreYou.WhereAreYouWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = whereareyouWidget; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 8CF0A00F2F90000100A1B2C3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 2ZF894QLGK; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhereAreYouWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WhereAreYouWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = kr.syl.WhereAreYou.WhereAreYouWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = whereareyouWidget; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -410,6 +592,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8CF0A00D2F90000100A1B2C3 /* Build configuration list for PBXNativeTarget "WhereAreYouWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8CF0A00E2F90000100A1B2C3 /* Debug */, + 8CF0A00F2F90000100A1B2C3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/WhereAreYou/WhereAreYou/Core/Info.plist b/WhereAreYou/WhereAreYou/Core/Info.plist index 6103349..69df8bb 100644 --- a/WhereAreYou/WhereAreYou/Core/Info.plist +++ b/WhereAreYou/WhereAreYou/Core/Info.plist @@ -23,6 +23,19 @@ NSLocalNetworkUsageDescription Firebase 에뮬레이터에 연결하기 위해 로컬 네트워크 접근이 필요합니다. + NSSupportsLiveActivities + + CFBundleURLTypes + + + CFBundleURLName + kr.syl.WhereAreYou + CFBundleURLSchemes + + whereareyou + + + UIBackgroundModes remote-notification diff --git a/WhereAreYou/WhereAreYouWidget/AppointmentRouteDisplay.swift b/WhereAreYou/WhereAreYouWidget/AppointmentRouteDisplay.swift new file mode 100644 index 0000000..3e09b98 --- /dev/null +++ b/WhereAreYou/WhereAreYouWidget/AppointmentRouteDisplay.swift @@ -0,0 +1,91 @@ +// +// AppointmentRouteDisplay.swift +// WhereAreYouWidget +// +// Created by 이상유 on 2026-09-26. +// + +import Foundation + +/// 라이브 액티비티에 보여줄 문구와 진행 위치 계산 — 속성(고정)과 상태(변경)를 합쳐 한 화면 분량의 값을 만든다 +struct AppointmentRouteDisplay { + + let attributes: AppointmentRouteActivityAttributes + let state: AppointmentRouteActivityAttributes.ContentState + /// 약속 시간이 지났는지. staleDate(약속 시각)가 지나 시스템이 다시 그렸거나, 그리는 시점에 이미 지난 경우 + let isPastAppointmentTime: Bool + + var isArrived: Bool { + state.reachedCheckpointCount >= attributes.checkpoints.count + } + + var hasDeparted: Bool { + state.reachedCheckpointCount > 0 + } + + /// 다음에 물어볼 확인 지점. 도착했으면 nil + var nextCheckpoint: AppointmentRouteActivityAttributes.Checkpoint? { + attributes.checkpoints.indices.contains(state.reachedCheckpointCount) + ? attributes.checkpoints[state.reachedCheckpointCount] + : nil + } + + /// 지금 이동 중인 구간. 출발 전이면 nil, 도착했으면 구간 수와 같다 + var currentSegmentIndex: Int? { + guard hasDeparted else { return nil } + let lastReachedIndex = min(state.reachedCheckpointCount, attributes.checkpoints.count) - 1 + return attributes.checkpoints[lastReachedIndex].segmentIndex + } + + var currentSegment: AppointmentRouteActivityAttributes.Segment? { + guard let currentSegmentIndex, attributes.segments.indices.contains(currentSegmentIndex) else { return nil } + return attributes.segments[currentSegmentIndex] + } + + var arrivalText: String { + "\(state.arrivedParticipantCount)/\(attributes.participantCount) 도착" + } + + var appointmentSummaryText: String { + "\(Self.timeFormatter.string(from: attributes.appointmentTime)) · \(attributes.placeName)" + } + + /// 현재 구간 1줄 — "강남역까지 4정거장", "이수역까지 도보 6분" + var statusText: String { + if isArrived { return "도착했어요" } + guard hasDeparted else { + let departureTime = attributes.checkpoints.first?.scheduledTime ?? attributes.appointmentTime + return "\(Self.timeFormatter.string(from: departureTime)) 출발 예정" + } + guard let segment = currentSegment else { return "" } + + switch segment.transport { + case .subway, .bus: + if let stopCount = segment.stopCount { + return "\(segment.destinationName)까지 \(stopCount)정거장" + } + return "\(segment.destinationName)까지 \(segment.estimatedMinutes)분" + case .walk: + return "\(segment.destinationName)까지 도보 \(segment.estimatedMinutes)분" + case .car: + return "\(segment.destinationName)까지 차로 \(segment.estimatedMinutes)분" + } + } + + var nextQuestionText: String? { + guard let nextCheckpoint else { return nil } + switch nextCheckpoint.kind { + case .departure: return "출발했나요?" + case .boarding, .alighting: return "\(nextCheckpoint.placeName)에 도착했나요?" + case .arrival: return "도착했나요?" + } + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "a h:mm" + return formatter + }() + +} diff --git a/WhereAreYou/WhereAreYouWidget/AppointmentRouteLiveActivity.swift b/WhereAreYou/WhereAreYouWidget/AppointmentRouteLiveActivity.swift new file mode 100644 index 0000000..02c9d2a --- /dev/null +++ b/WhereAreYou/WhereAreYouWidget/AppointmentRouteLiveActivity.swift @@ -0,0 +1,301 @@ +// +// AppointmentRouteLiveActivity.swift +// WhereAreYouWidget +// +// Created by 이상유 on 2026-09-26. +// + +import ActivityKit +import AppIntents +import SwiftUI +import WidgetKit + +/// 약속 이동 라이브 액티비티 — 잠금화면 배너와 다이나믹 아일랜드(확장·컴팩트·최소) 배치. 탭하면 약속 지도로 이동 +struct AppointmentRouteLiveActivity: Widget { + + var body: some WidgetConfiguration { + ActivityConfiguration(for: AppointmentRouteActivityAttributes.self) { context in + AppointmentRouteLockScreenView(display: context.display) + .activityBackgroundTint(Color(.systemBackground).opacity(0.85)) + .widgetURL(AppointmentDeepLink.route(appointmentID: context.attributes.appointmentID).url) + } dynamicIsland: { context in + let display = context.display + + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Text(display.attributes.appointmentName) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + .padding(.leading, 8) + } + DynamicIslandExpandedRegion(.trailing) { + Label(display.arrivalText, systemImage: "person.2.fill") + .font(.caption.weight(.semibold)) + .lineLimit(1) + .padding(.trailing, 8) + } + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 8) { + RouteProgressBar(segments: display.attributes.segments, currentIndex: display.currentSegmentIndex) + RouteStatusRow(display: display) + if !display.isArrived { + RouteActionButtons(display: display) + } + } + .padding(.horizontal, 8) + .padding(.top, 4) + } + } compactLeading: { + // 아이콘만 두면 왼쪽 끝에 붙으므로 오른쪽 타이머와 비슷한 폭 안에서 가운데 둔다 + CompactSegmentIcon(display: display) + .frame(width: 36) + } compactTrailing: { + RemainingTimeText(display: display) + .font(.caption2.weight(.semibold)) + .frame(maxWidth: 44) + } minimal: { + CompactSegmentIcon(display: display) + } + .widgetURL(AppointmentDeepLink.route(appointmentID: context.attributes.appointmentID).url) + .keylineTint(WidgetPalette.accent) + } + } + +} + +private extension ActivityViewContext { + + var display: AppointmentRouteDisplay { + AppointmentRouteDisplay( + attributes: attributes, + state: state, + isPastAppointmentTime: isStale || Date() >= attributes.appointmentTime + ) + } + +} + +// MARK: - 잠금화면 + +/// 잠금화면 배너 — 약속 요약, 남은 시간, 진행 바, 현재 구간, 버튼 2개. 도착하면 버튼 없이 "도착했어요"만 남는다 +struct AppointmentRouteLockScreenView: View { + + let display: AppointmentRouteDisplay + + var body: some View { + // 잠금화면 라이브 액티비티는 높이 160pt를 넘으면 그려지지 않는다. 줄 간격·위아래 여백을 늘릴 때 주의 + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text(display.attributes.appointmentName) + .font(.headline) + .lineLimit(1) + Spacer(minLength: 8) + Label(display.arrivalText, systemImage: "person.2.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(WidgetPalette.accent) + } + + HStack(alignment: .firstTextBaseline) { + Text(display.appointmentSummaryText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 8) + if !display.isArrived { + HStack(spacing: 4) { + Text(display.isPastAppointmentTime ? "약속 시간" : "약속까지") + .font(.caption) + .foregroundStyle(.secondary) + // 타이머 Text는 남는 폭을 모두 차지하고 fixedSize도 쓸 수 없어, "mmm:ss"가 들어가는 폭으로 고정한다 + RemainingTimeText(display: display) + .font(.caption.weight(.semibold)) + .frame(width: 44, alignment: .trailing) + } + } + } + + RouteProgressBar(segments: display.attributes.segments, currentIndex: display.currentSegmentIndex) + + RouteStatusRow(display: display) + + if !display.isArrived { + RouteActionButtons(display: display) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + +} + +// MARK: - 공용 구성요소 + +/// 현재 구간 1줄 +struct RouteStatusRow: View { + + let display: AppointmentRouteDisplay + + var body: some View { + HStack(spacing: 6) { + if let segment = display.currentSegment { + RouteSegmentChip(segment: segment) + } + Text(display.statusText) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 0) + } + } + +} + +/// 위치 공유(앱을 열어 바로 전송)와 단계 진행(LiveActivityIntent) 버튼. 도착 후에는 표시하지 않는다 +struct RouteActionButtons: View { + + let display: AppointmentRouteDisplay + + var body: some View { + HStack(spacing: 8) { + Link(destination: AppointmentDeepLink.shareLocation(appointmentID: display.attributes.appointmentID).url) { + Label("위치 공유", systemImage: "location.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(WidgetPalette.accent) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Capsule().fill(WidgetPalette.accent.opacity(0.15))) + } + + if let question = display.nextQuestionText { + Button(intent: AdvanceRouteProgressIntent(appointmentID: display.attributes.appointmentID)) { + Label(question, systemImage: "checkmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Capsule().fill(WidgetPalette.accent)) + } + .buttonStyle(.plain) + } + } + } + +} + +/// 약속 시각까지 남은 시간 — 지나기 전에는 카운트다운, 지난 뒤에는 빨간 "+경과 시간" 카운트업. 시간 단위 없이 분:초로 표시한다 +struct RemainingTimeText: View { + + /// 미도착 액티비티는 약속 + 1시간에 서버 종료 푸시로 내릴 예정(3단계)이지만, 그 전까지는 계속 세도록 넉넉히 잡는다 + private static let overdueTimerRange: TimeInterval = 8 * 60 * 60 + + let display: AppointmentRouteDisplay + + var body: some View { + let appointmentTime = display.attributes.appointmentTime + if display.isPastAppointmentTime { + // "+"를 따로 두면 타이머가 남는 폭을 차지해 사이가 벌어지므로 한 Text로 잇는다 + (Text("+") + Text( + timerInterval: appointmentTime...appointmentTime.addingTimeInterval(Self.overdueTimerRange), + countsDown: false, + showsHours: false + )) + .monospacedDigit() + .multilineTextAlignment(.trailing) + .foregroundStyle(WidgetPalette.overdue) + } else { + let now = Date() + Text(timerInterval: now...max(now, appointmentTime), countsDown: true, showsHours: false) + .monospacedDigit() + .multilineTextAlignment(.trailing) + } + } + +} + +/// 다이나믹 아일랜드 컴팩트·최소 영역 — 지금 구간의 수단 아이콘을 노선 색으로 +struct CompactSegmentIcon: View { + + let display: AppointmentRouteDisplay + + var body: some View { + if display.isArrived { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(WidgetPalette.accent) + } else if let segment = display.currentSegment { + Image(systemName: segment.symbolName) + .foregroundStyle(segment.tint) + } else { + Image(systemName: "figure.walk.departure") + .foregroundStyle(WidgetPalette.accent) + } + } + +} + +// MARK: - Preview + +#if DEBUG +private extension AppointmentRouteActivityAttributes { + + static var preview: AppointmentRouteActivityAttributes { + makePreview(departure: Date().addingTimeInterval(60)) + } + + /// 약속 시각이 5분 지난 상태 + static var previewPastAppointment: AppointmentRouteActivityAttributes { + makePreview(departure: Date().addingTimeInterval(-29 * 60)) + } + + private static func makePreview(departure: Date) -> AppointmentRouteActivityAttributes { + AppointmentRouteActivityAttributes( + appointmentID: "preview", + appointmentName: "고기굽는방앗간 이수역점", + placeName: "스타벅스 강남역점", + appointmentTime: departure.addingTimeInterval(24 * 60), + participantCount: 6, + segments: [ + Segment(transport: .walk, destinationName: "이수역", estimatedMinutes: 6, lineName: nil, lineColorHex: nil, stopCount: nil), + Segment(transport: .subway, destinationName: "사당역", estimatedMinutes: 2, lineName: "4호선", lineColorHex: "#00A5DE", stopCount: 1), + Segment(transport: .walk, destinationName: "사당역", estimatedMinutes: 3, lineName: nil, lineColorHex: nil, stopCount: nil), + Segment(transport: .subway, destinationName: "강남역", estimatedMinutes: 8, lineName: "2호선", lineColorHex: "#00A84D", stopCount: 4), + Segment(transport: .walk, destinationName: "스타벅스 강남역점", estimatedMinutes: 4, lineName: nil, lineColorHex: nil, stopCount: nil), + ], + checkpoints: [ + Checkpoint(kind: .departure, placeName: "출발지", scheduledTime: departure, segmentIndex: 0), + Checkpoint(kind: .boarding, placeName: "이수역", scheduledTime: departure.addingTimeInterval(6 * 60), segmentIndex: 1), + Checkpoint(kind: .alighting, placeName: "사당역", scheduledTime: departure.addingTimeInterval(8 * 60), segmentIndex: 3), + Checkpoint(kind: .alighting, placeName: "강남역", scheduledTime: departure.addingTimeInterval(19 * 60), segmentIndex: 4), + Checkpoint(kind: .arrival, placeName: "스타벅스 강남역점", scheduledTime: departure.addingTimeInterval(23 * 60), segmentIndex: 5), + ] + ) + } + +} + +#Preview("잠금화면", as: .content, using: AppointmentRouteActivityAttributes.preview) { + AppointmentRouteLiveActivity() +} contentStates: { + AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 0, arrivedParticipantCount: 0) + AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 3, arrivedParticipantCount: 2) + AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 5, arrivedParticipantCount: 3) +} + +#Preview("잠금화면 (약속 시간 지남)", as: .content, using: AppointmentRouteActivityAttributes.previewPastAppointment) { + AppointmentRouteLiveActivity() +} contentStates: { + AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 3, arrivedParticipantCount: 4) +} + +#Preview("다이나믹 아일랜드 확장", as: .dynamicIsland(.expanded), using: AppointmentRouteActivityAttributes.preview) { + AppointmentRouteLiveActivity() +} contentStates: { + AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 3, arrivedParticipantCount: 2) +} + +#Preview("다이나믹 아일랜드 컴팩트", as: .dynamicIsland(.compact), using: AppointmentRouteActivityAttributes.preview) { + AppointmentRouteLiveActivity() +} contentStates: { + AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 0, arrivedParticipantCount: 0) + AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 3, arrivedParticipantCount: 2) +} +#endif diff --git a/WhereAreYou/WhereAreYouWidget/Info.plist b/WhereAreYou/WhereAreYouWidget/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/WhereAreYou/WhereAreYouWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/WhereAreYou/WhereAreYouWidget/RouteProgressBar.swift b/WhereAreYou/WhereAreYouWidget/RouteProgressBar.swift new file mode 100644 index 0000000..0434d2d --- /dev/null +++ b/WhereAreYou/WhereAreYouWidget/RouteProgressBar.swift @@ -0,0 +1,99 @@ +// +// RouteProgressBar.swift +// WhereAreYouWidget +// +// Created by 이상유 on 2026-09-26. +// + +import SwiftUI + +/// 경로 진행 바 — 구간마다 도보·자동차 아이콘 또는 노선 색 칩을 잇고, 지나온 구간과 남은 구간을 흐리기로 구분한다 +struct RouteProgressBar: View { + + let segments: [AppointmentRouteActivityAttributes.Segment] + /// 출발 전이면 nil, 도착했으면 segments.count + let currentIndex: Int? + + var body: some View { + HStack(spacing: 4) { + ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in + if index > 0 { + Capsule() + .fill(isPassed(index) || isCurrent(index) ? WidgetPalette.accent : Color.secondary.opacity(0.3)) + .frame(height: 2) + .frame(minWidth: 6) + } + RouteSegmentChip(segment: segment) + .opacity(isPassed(index) ? 0.45 : (isCurrent(index) || currentIndex == nil ? 1 : 0.7)) + .overlay { + if isCurrent(index) { + Capsule().stroke(WidgetPalette.accent, lineWidth: 2).padding(-2) + } + } + } + } + } + + private func isPassed(_ index: Int) -> Bool { + guard let currentIndex else { return false } + return index < currentIndex + } + + private func isCurrent(_ index: Int) -> Bool { + index == currentIndex + } + +} + +/// 구간 칩 하나 — 대중교통은 노선 색 캡슐에 노선명(버스는 아이콘 + 번호), 도보·자동차는 아이콘 +struct RouteSegmentChip: View { + + let segment: AppointmentRouteActivityAttributes.Segment + + var body: some View { + Group { + if let lineName = segment.lineName { + // "4호선"은 이름만으로 지하철인 걸 알 수 있지만 버스 번호는 숫자뿐이라 아이콘을 붙인다 + HStack(spacing: 2) { + if segment.transport == .bus { + Image(systemName: segment.symbolName) + } + Text(lineName) + .lineLimit(1) + .fixedSize() + } + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + } else { + Image(systemName: segment.symbolName) + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + } + } + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(Capsule().fill(segment.tint)) + } + +} + +extension AppointmentRouteActivityAttributes.Segment { + + var symbolName: String { + switch transport { + case .walk: return "figure.walk" + case .car: return "car.fill" + case .subway: return "tram.fill" + case .bus: return "bus.fill" + } + } + + var tint: Color { + switch transport { + case .walk: return WidgetPalette.walk + case .car: return WidgetPalette.car + case .subway, .bus: return lineColorHex.flatMap(Color.init(hex:)) ?? WidgetPalette.transitFallback + } + } + +} diff --git a/WhereAreYou/WhereAreYouWidget/WhereAreYouWidgetBundle.swift b/WhereAreYou/WhereAreYouWidget/WhereAreYouWidgetBundle.swift new file mode 100644 index 0000000..03f163a --- /dev/null +++ b/WhereAreYou/WhereAreYouWidget/WhereAreYouWidgetBundle.swift @@ -0,0 +1,19 @@ +// +// WhereAreYouWidgetBundle.swift +// WhereAreYouWidget +// +// Created by 이상유 on 2026-09-26. +// + +import SwiftUI +import WidgetKit + +/// 위젯 익스텐션 진입점 — 현재는 약속 이동 라이브 액티비티만 제공한다 +@main +struct WhereAreYouWidgetBundle: WidgetBundle { + + var body: some Widget { + AppointmentRouteLiveActivity() + } + +} diff --git a/WhereAreYou/WhereAreYouWidget/WidgetPalette.swift b/WhereAreYou/WhereAreYouWidget/WidgetPalette.swift new file mode 100644 index 0000000..6be8324 --- /dev/null +++ b/WhereAreYou/WhereAreYouWidget/WidgetPalette.swift @@ -0,0 +1,39 @@ +// +// WidgetPalette.swift +// WhereAreYouWidget +// +// Created by 이상유 on 2026-09-26. +// + +import SwiftUI + +/// 위젯 익스텐션 색상 — 앱 에셋 카탈로그를 쓸 수 없어 필요한 색만 옮겨 둔다 +enum WidgetPalette { + + /// 앱 pointBlue + static let accent = Color(hex: "#4E7AC7") ?? .blue + /// 약속 시간이 지난 뒤의 경과 시간 — 앱 customRed + static let overdue = Color(hex: "#C94F5C") ?? .red + /// 도보 구간 — 초록 노선(2호선 등)과 헷갈리지 않도록 무채색 + static let walk = Color.gray + /// 자동차 구간 — 앱의 TransportType.car 색과 같다 + static let car = Color.indigo + /// 노선 색 정보가 없는 대중교통 구간 — 앱의 TransportType.transit 색과 같다 + static let transitFallback = Color.yellow + +} + +extension Color { + + /// "#RRGGBB" 형식만 받는다. 형식이 다르면 nil + nonisolated init?(hex: String) { + let value = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex + guard value.count == 6, let rgb = UInt32(value, radix: 16) else { return nil } + self.init( + red: Double((rgb >> 16) & 0xFF) / 255, + green: Double((rgb >> 8) & 0xFF) / 255, + blue: Double(rgb & 0xFF) / 255 + ) + } + +} From ce851df50827ca5800d6380fc5ba9d6cb6ee30a7 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:03:54 +0900 Subject: [PATCH 07/23] =?UTF-8?q?FEAT:=20ActivityKit=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=95=A1=ED=8B=B0=EB=B9=84?= =?UTF-8?q?=ED=8B=B0=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tivityKitRouteLiveActivityRepository.swift | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Data/ActivityKitRouteLiveActivityRepository.swift diff --git a/WhereAreYou/WhereAreYou/Data/ActivityKitRouteLiveActivityRepository.swift b/WhereAreYou/WhereAreYou/Data/ActivityKitRouteLiveActivityRepository.swift new file mode 100644 index 0000000..93a513e --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/ActivityKitRouteLiveActivityRepository.swift @@ -0,0 +1,150 @@ +// +// ActivityKitRouteLiveActivityRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +import Foundation +import ActivityKit + +/// ActivityKit으로 진행 중인 약속 이동 라이브 액티비티를 조회·갱신·종료한다 — 액티비티 모델 → 도메인 모델 변환 포함. +/// 약속 시각을 staleDate로 걸어, 약속 시간이 지나면 시스템이 위젯을 다시 그려 경과 시간을 빨갛게 표시하게 한다. +/// 시작은 3단계에서 서버 push-to-start가 맡으므로, 임시 시작 코드는 DebugLiveActivity 폴더의 `+Start` 확장에 따로 둔다 +final class ActivityKitRouteLiveActivityRepository: RouteLiveActivityRepository { + + typealias Attributes = AppointmentRouteActivityAttributes + + func current(appointmentID: String) -> (plan: RouteProgressPlan, status: RouteProgressStatus)? { + guard let activity = activities(appointmentID: appointmentID).first(where: { Self.isOngoing($0) }) else { + return nil + } + return Self.domainModel(of: activity) + } + + func update(appointmentID: String, status: RouteProgressStatus) async { + for activity in activities(appointmentID: appointmentID) { + await activity.update(content(status: status, attributes: activity.attributes)) + } + } + + func end(appointmentID: String, status: RouteProgressStatus, dismissalDate: Date) async { + for activity in activities(appointmentID: appointmentID) { + await activity.end( + content(status: status, attributes: activity.attributes), + dismissalPolicy: .after(dismissalDate) + ) + } + } + + private func activities(appointmentID: String) -> [Activity] { + Activity.activities.filter { $0.attributes.appointmentID == appointmentID } + } + + /// DebugLiveActivity의 시작 확장도 같은 staleDate 규칙을 쓰도록 internal로 둔다 + func content(status: RouteProgressStatus, attributes: Attributes) -> ActivityContent { + ActivityContent(state: Attributes.ContentState(status: status), staleDate: attributes.appointmentTime) + } + + /// 약속 시각이 지나 stale이 된 액티비티도 버튼은 계속 눌리므로 종료·제거된 것만 뺀다 + private static func isOngoing(_ activity: Activity) -> Bool { + activity.activityState == .active || activity.activityState == .stale + } + + private static func domainModel(of activity: Activity) -> (plan: RouteProgressPlan, status: RouteProgressStatus) { + (RouteProgressPlan(attributes: activity.attributes), RouteProgressStatus(state: activity.content.state)) + } + +} + +// MARK: - 도메인 상태 → 액티비티 상태 (갱신·종료용) + +private extension AppointmentRouteActivityAttributes.ContentState { + + init(status: RouteProgressStatus) { + self.init( + reachedCheckpointCount: status.reachedCheckpointCount, + arrivedParticipantCount: status.arrivedParticipantCount + ) + } + +} + +// MARK: - 액티비티 모델 → 도메인 + +private extension AppointmentRouteActivityAttributes.Segment.Transport { + + var transportType: TransportType { + switch self { + case .walk: return .walk + case .car: return .car + case .subway, .bus: return .transit + } + } + + var transitVehicle: RouteProgressSegment.TransitVehicle? { + switch self { + case .walk, .car: return nil + case .subway: return .subway + case .bus: return .bus + } + } + +} + +private extension AppointmentRouteActivityAttributes.Checkpoint.Kind { + + var checkpointKind: RouteCheckpoint.Kind { + switch self { + case .departure: return .departure + case .boarding: return .boarding + case .alighting: return .alighting + case .arrival: return .arrival + } + } + +} + +private extension RouteProgressPlan { + + init(attributes: AppointmentRouteActivityAttributes) { + self.init( + appointmentID: attributes.appointmentID, + appointmentName: attributes.appointmentName, + placeName: attributes.placeName, + appointmentTime: attributes.appointmentTime, + participantCount: attributes.participantCount, + segments: attributes.segments.map { segment in + RouteProgressSegment( + transportType: segment.transport.transportType, + transitVehicle: segment.transport.transitVehicle, + destinationName: segment.destinationName, + estimatedMinutes: segment.estimatedMinutes, + lineName: segment.lineName, + lineColorHex: segment.lineColorHex, + stopCount: segment.stopCount + ) + }, + checkpoints: attributes.checkpoints.map { checkpoint in + RouteCheckpoint( + kind: checkpoint.kind.checkpointKind, + placeName: checkpoint.placeName, + scheduledTime: checkpoint.scheduledTime, + segmentIndex: checkpoint.segmentIndex + ) + } + ) + } + +} + +private extension RouteProgressStatus { + + init(state: AppointmentRouteActivityAttributes.ContentState) { + self.init( + reachedCheckpointCount: state.reachedCheckpointCount, + arrivedParticipantCount: state.arrivedParticipantCount + ) + } + +} From 800bae71de795dc2cf17a2e8dbcb7244659d3117 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:03:54 +0900 Subject: [PATCH 08/23] =?UTF-8?q?FEAT:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=98=84=ED=99=A9=20=ED=97=88=EC=9A=A9=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...yKitLiveActivityPermissionRepository.swift | 49 +++++++++++++++++++ .../Entity/LiveActivityPermissionStatus.swift | 16 ++++++ .../LiveActivityPermissionRepository.swift | 18 +++++++ ...ObserveLiveActivityPermissionUseCase.swift | 29 +++++++++++ 4 files changed, 112 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Data/ActivityKitLiveActivityPermissionRepository.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/Entity/LiveActivityPermissionStatus.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/Repository/LiveActivityPermissionRepository.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/UseCase/ObserveLiveActivityPermissionUseCase.swift diff --git a/WhereAreYou/WhereAreYou/Data/ActivityKitLiveActivityPermissionRepository.swift b/WhereAreYou/WhereAreYou/Data/ActivityKitLiveActivityPermissionRepository.swift new file mode 100644 index 0000000..c015181 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/ActivityKitLiveActivityPermissionRepository.swift @@ -0,0 +1,49 @@ +// +// ActivityKitLiveActivityPermissionRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +import ActivityKit +import Combine + +/// ActivityAuthorizationInfo로 실시간 현황 허용 상태를 읽고, 설정 앱에서 바뀌면 발행한다 +final class ActivityKitLiveActivityPermissionRepository: LiveActivityPermissionRepository { + + private let authorizationInfo = ActivityAuthorizationInfo() + private let authorizationStatusSubject: CurrentValueSubject + private var enablementObservationTask: Task? + + init() { + authorizationStatusSubject = CurrentValueSubject( + LiveActivityPermissionStatus(isEnabled: authorizationInfo.areActivitiesEnabled) + ) + enablementObservationTask = Task { [weak self, authorizationInfo] in + for await isEnabled in authorizationInfo.activityEnablementUpdates { + self?.authorizationStatusSubject.send(LiveActivityPermissionStatus(isEnabled: isEnabled)) + } + } + } + + deinit { + enablementObservationTask?.cancel() + } + + var authorizationStatus: LiveActivityPermissionStatus { + LiveActivityPermissionStatus(isEnabled: authorizationInfo.areActivitiesEnabled) + } + + var authorizationStatusPublisher: AnyPublisher { + authorizationStatusSubject.eraseToAnyPublisher() + } + +} + +private extension LiveActivityPermissionStatus { + + init(isEnabled: Bool) { + self = isEnabled ? .allowed : .denied + } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Entity/LiveActivityPermissionStatus.swift b/WhereAreYou/WhereAreYou/Domain/Entity/LiveActivityPermissionStatus.swift new file mode 100644 index 0000000..4c0e5d2 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Entity/LiveActivityPermissionStatus.swift @@ -0,0 +1,16 @@ +// +// LiveActivityPermissionStatus.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +/// 실시간 현황(라이브 액티비티) 허용 상태. 위치 권한과 달리 앱에서 요청할 수 없고 설정 앱에서만 바꿀 수 있다 +enum LiveActivityPermissionStatus { + + /// 설정 앱에서 실시간 현황이 켜진 상태 (기본값) + case allowed + /// 사용자가 설정 앱이나 잠금화면에서 실시간 현황을 끈 상태 + case denied + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/LiveActivityPermissionRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/LiveActivityPermissionRepository.swift new file mode 100644 index 0000000..c5fd630 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Repository/LiveActivityPermissionRepository.swift @@ -0,0 +1,18 @@ +// +// LiveActivityPermissionRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +import Combine + +/// 실시간 현황 허용 상태의 조회 및 구독을 담당 +protocol LiveActivityPermissionRepository { + + /// 현재 실시간 현황 허용 상태 + var authorizationStatus: LiveActivityPermissionStatus { get } + /// 허용 상태가 바뀔 때마다 발행되는 스트림 + var authorizationStatusPublisher: AnyPublisher { get } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/ObserveLiveActivityPermissionUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/ObserveLiveActivityPermissionUseCase.swift new file mode 100644 index 0000000..1d70dae --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/ObserveLiveActivityPermissionUseCase.swift @@ -0,0 +1,29 @@ +// +// ObserveLiveActivityPermissionUseCase.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +import Combine + +/// 실시간 현황 허용 상태의 조회와 구독을 담당. 요청 기능은 없다 — 설정 앱에서만 바꿀 수 있다 +final class ObserveLiveActivityPermissionUseCase { + + private let repository: LiveActivityPermissionRepository + + init(repository: LiveActivityPermissionRepository) { + self.repository = repository + } + + /// 현재 실시간 현황 허용 상태 + var currentStatus: LiveActivityPermissionStatus { + repository.authorizationStatus + } + + /// 허용 상태가 바뀔 때마다 발행되는 스트림 + var statusPublisher: AnyPublisher { + repository.authorizationStatusPublisher + } + +} From 4574d5a53565ef50f7c0c81aea730d814b6fb3c5 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:03:54 +0900 Subject: [PATCH 09/23] =?UTF-8?q?FEAT:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=95=A1=ED=8B=B0=EB=B9=84=ED=8B=B0=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20DI=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/DI/Implementation/AppDIContainer+Register.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift index 1b735b6..7ef2fa5 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift @@ -62,6 +62,14 @@ extension DIContainer { // 길찾기 register(RouteSearchRepository.self) { MockRouteSearchRepository() } + + // 약속 이동 라이브 액티비티 + register(RouteLiveActivityRepository.self, instance: ActivityKitRouteLiveActivityRepository()) + register(LiveActivityPermissionRepository.self, instance: ActivityKitLiveActivityPermissionRepository()) + // 단계 버튼(LiveActivityIntent)이 앱 프로세스에서 이 서비스를 꺼내 쓴다 + register(RouteProgressService.self, instance: RouteProgressService( + liveActivityRepository: resolve(RouteLiveActivityRepository.self) + )) } } From f9a5354cb3e3e31054ca798ff8714f7c61b20c36 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:04:04 +0900 Subject: [PATCH 10/23] =?UTF-8?q?FEAT:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=ED=98=84?= =?UTF-8?q?=ED=99=A9=20=EC=84=A4=EC=A0=95=20=ED=96=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Implementation/ScreenFactory+MyPage.swift | 3 +++ .../LiveActivityPermissionState+.swift | 17 +++++++++++++++ .../MyPage/MyPageViewController.swift | 18 +++++++++++++++- .../Presentation/MyPage/MyPageViewModel.swift | 20 ++++++++++++++++++ .../LiveActivityPermissionState.swift | 21 +++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 WhereAreYou/WhereAreYou/Presentation/Extension/LiveActivityPermissionState+.swift create mode 100644 WhereAreYou/WhereAreYou/Presentation/PresentationModel/LiveActivityPermissionState.swift diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+MyPage.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+MyPage.swift index 12cf2bb..417cd64 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+MyPage.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+MyPage.swift @@ -12,6 +12,9 @@ extension ScreenFactory { observeLocationPermissionUseCase: ObserveLocationPermissionUseCase( repository: container.resolve(LocationPermissionRepository.self) ), + observeLiveActivityPermissionUseCase: ObserveLiveActivityPermissionUseCase( + repository: container.resolve(LiveActivityPermissionRepository.self) + ), signOutUseCase: SignOutUseCase( authRepository: container.resolve(AuthRepository.self), fcmTokenRepository: container.resolve(FCMTokenRepository.self) diff --git a/WhereAreYou/WhereAreYou/Presentation/Extension/LiveActivityPermissionState+.swift b/WhereAreYou/WhereAreYou/Presentation/Extension/LiveActivityPermissionState+.swift new file mode 100644 index 0000000..9665a57 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Extension/LiveActivityPermissionState+.swift @@ -0,0 +1,17 @@ +// +// LiveActivityPermissionState+.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +extension LiveActivityPermissionState { + + var title: String { + switch self { + case .allowed: return "허용" + case .denied: return "허용 안 함" + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift index 1ae6d9a..29e7108 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift @@ -74,6 +74,10 @@ final class MyPageViewController: UIViewController { private let locationCard = MyPageCardView() private let locationSharingRow = MyPageRow(icon: UIImage(systemName: "location"), title: "위치 공유 설정") private let locationPermissionRow = MyPageRow(icon: UIImage(systemName: "location.circle"), title: "위치 권한 설정") + private let liveActivityPermissionRow = MyPageRow( + icon: UIImage(systemName: "rectangle.bottomhalf.filled"), + title: "실시간 현황 설정" + ) // MARK: - 알림 카드 @@ -126,7 +130,7 @@ final class MyPageViewController: UIViewController { scrollView.addSubview(contentStack) setUpCard(profileCard, rows: [profileRow]) - setUpCard(locationCard, rows: [locationSharingRow, locationPermissionRow]) + setUpCard(locationCard, rows: [locationSharingRow, locationPermissionRow, liveActivityPermissionRow]) setUpCard(notificationCard, rows: [notificationRow, appointmentNotificationRow]) setUpCard(etcCard, rows: [appInfoRow, signOutRow]) @@ -186,6 +190,11 @@ final class MyPageViewController: UIViewController { locationPermissionRow.onTap = { [weak self] in self?.presentLocationPermission() } + // 실시간 현황은 앱에서 요청할 수 없어 설정 앱의 앱 설정 화면으로 바로 보낸다 + liveActivityPermissionRow.onTap = { + guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(settingsURL) + } appointmentNotificationRow.onTap = { [weak self] in self?.presentAppointmentNotificationList() } @@ -243,6 +252,13 @@ final class MyPageViewController: UIViewController { } private func bindViewModel() { + viewModel.$liveActivityPermissionState + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.liveActivityPermissionRow.value = state.title + } + .store(in: &cancellables) + viewModel.$signOutResult .compactMap { $0 } .receive(on: DispatchQueue.main) diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift index c36c079..d9292af 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift @@ -13,6 +13,8 @@ import Foundation // - appointmentNotifications: 약속별 알림 화면에서 쓸 약속 목록 // - locationPermissionState: 위치 권한 상태를 표시용 타입으로 변환해 발행 // (실제 CoreLocation 접근은 Data 계층이 담당하며, ObserveLocationPermissionUseCase를 통해 전달받음) +// - liveActivityPermissionState: 실시간 현황 허용 상태를 표시용 타입으로 변환해 발행 +// (ActivityKit 접근은 Data 계층이 담당하며, ObserveLiveActivityPermissionUseCase를 통해 전달받음) final class MyPageViewModel { @@ -20,24 +22,30 @@ final class MyPageViewModel { private(set) var appointmentNotifications: [AppointmentListItem] = [] @Published private(set) var locationPermissionState: LocationPermissionState + @Published private(set) var liveActivityPermissionState: LiveActivityPermissionState @Published private(set) var signOutResult: Result? private let observeLocationPermissionUseCase: ObserveLocationPermissionUseCase + private let observeLiveActivityPermissionUseCase: ObserveLiveActivityPermissionUseCase private let signOutUseCase: SignOutUseCase private var cancellables = Set() init( profile: MyPageProfile? = nil, observeLocationPermissionUseCase: ObserveLocationPermissionUseCase, + observeLiveActivityPermissionUseCase: ObserveLiveActivityPermissionUseCase, signOutUseCase: SignOutUseCase ) { self.profile = profile ?? Self.makeDummyProfile() self.observeLocationPermissionUseCase = observeLocationPermissionUseCase + self.observeLiveActivityPermissionUseCase = observeLiveActivityPermissionUseCase self.signOutUseCase = signOutUseCase self.locationPermissionState = LocationPermissionState(observeLocationPermissionUseCase.currentStatus) + self.liveActivityPermissionState = LiveActivityPermissionState(observeLiveActivityPermissionUseCase.currentStatus) loadDummyAppointmentNotifications() bindLocationPermission() + bindLiveActivityPermission() } func signOut() { @@ -183,3 +191,15 @@ extension MyPageViewModel { } } + +// MARK: - 실시간 현황 권한 + +extension MyPageViewModel { + + private func bindLiveActivityPermission() { + observeLiveActivityPermissionUseCase.statusPublisher + .map(LiveActivityPermissionState.init) + .assign(to: &$liveActivityPermissionState) + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/PresentationModel/LiveActivityPermissionState.swift b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/LiveActivityPermissionState.swift new file mode 100644 index 0000000..10611c7 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/LiveActivityPermissionState.swift @@ -0,0 +1,21 @@ +// +// LiveActivityPermissionState.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +/// 마이페이지 실시간 현황 설정 행에서 사용하는 표시 전용 모델 +enum LiveActivityPermissionState: Equatable { + + case allowed + case denied + + nonisolated init(_ status: LiveActivityPermissionStatus) { + switch status { + case .allowed: self = .allowed + case .denied: self = .denied + } + } + +} From 3eff215de05f285d41c40a476b503b887e8527ae Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:04:18 +0900 Subject: [PATCH 11/23] =?UTF-8?q?FEAT:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=95=A1=ED=8B=B0=EB=B9=84=ED=8B=B0=20=ED=83=AD=C2=B7=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EA=B3=B5=EC=9C=A0=20=EB=94=A5=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Implementation/AppCoordinator.swift | 7 ++++++ .../AppointmentListCoordinator.swift | 12 +++++++++ .../Implementation/TabBarCoordinator.swift | 25 +++++++++++++++++++ .../WhereAreYou/Core/SceneDelegate.swift | 15 +++++++++++ .../Chat/ChatViewController.swift | 5 ++++ .../Presentation/Chat/ChatViewModel.swift | 23 +++++++++++++++++ 6 files changed, 87 insertions(+) diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppCoordinator.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppCoordinator.swift index 013504f..8b8bd83 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppCoordinator.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppCoordinator.swift @@ -34,6 +34,13 @@ final class AppCoordinator: Coordinator { window.makeKeyAndVisible() } + // MARK: - 딥링크 + + /// 로그인 전이면 무시한다 + func handle(deepLink: AppointmentDeepLink) { + tabBarCoordinator?.handle(deepLink: deepLink) + } + // MARK: - 자동 로그인 검증 private func validateUser() { diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppointmentListCoordinator.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppointmentListCoordinator.swift index a53fc29..2572e7f 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppointmentListCoordinator.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppointmentListCoordinator.swift @@ -39,11 +39,23 @@ final class AppointmentListCoordinator: NavigationCoordinator, AppointmentListCo // MARK: - Chat func showChat(appointmentID: String) { + showChat(appointmentID: appointmentID, sharesLocationImmediately: false) + } + + /// 라이브 액티비티의 위치 공유 버튼 — 채팅방을 열면서 확인 없이 내 위치를 바로 보낸다 + func showChatSharingLocation(appointmentID: String) { + showChat(appointmentID: appointmentID, sharesLocationImmediately: true) + } + + private func showChat(appointmentID: String, sharesLocationImmediately: Bool) { screenFactory.makeChatViewController(appointmentID: appointmentID) { [weak self] result in guard let self, case .success(let chatViewController) = result else { return } let coordinator = ChatCoordinator(navigationController: self.navigationController) self.chatCoordinator = coordinator chatViewController.coordinator = coordinator + if sharesLocationImmediately { + chatViewController.shareCurrentLocationAfterLoading() + } self.push(chatViewController) } } diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift index 217090c..46dedba 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift @@ -54,4 +54,29 @@ final class TabBarCoordinator: Coordinator { tabBarController.tabBar.tintColor = .blue1 } + // MARK: - 딥링크 + + /// 약속 탭으로 전환하고 떠 있던 모달·push 화면을 정리한 뒤 목적지 화면을 띄운다 + func handle(deepLink: AppointmentDeepLink) { + guard let appointmentListCoordinator else { return } + let navigationController = appointmentListCoordinator.navigationController + tabBarController.selectedViewController = navigationController + + let route = { + navigationController.popToRootViewController(animated: false) + switch deepLink { + case .route(let appointmentID): + appointmentListCoordinator.showAppointmentRoute(appointmentID: appointmentID) + case .shareLocation(let appointmentID): + appointmentListCoordinator.showChatSharingLocation(appointmentID: appointmentID) + } + } + + if tabBarController.presentedViewController != nil { + tabBarController.dismiss(animated: false, completion: route) + } else { + route() + } + } + } diff --git a/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift b/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift index 2df9cf8..aebd811 100644 --- a/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift @@ -20,6 +20,21 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let coordinator = AppCoordinator(window: window) appCoordinator = coordinator coordinator.start() + + if let url = connectionOptions.urlContexts.first?.url { + handle(url: url) + } + } + + /// 라이브 액티비티 탭·버튼으로 앱이 이미 떠 있는 상태에서 들어온 링크 + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let url = URLContexts.first?.url else { return } + handle(url: url) + } + + private func handle(url: URL) { + guard let deepLink = AppointmentDeepLink(url: url) else { return } + appCoordinator?.handle(deepLink: deepLink) } func sceneDidDisconnect(_ scene: UIScene) { } diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift index bf1db07..b507ecc 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift @@ -299,6 +299,11 @@ final class ChatViewController: UIViewController { return UIMenu(children: [viewSharedPlaces, searchPlace, shareLocation]) } + /// 라이브 액티비티 위치 공유 버튼으로 들어온 경우 호출 — push 전에 불러도 된다 + func shareCurrentLocationAfterLoading() { + viewModel.shareCurrentLocationAfterLoading() + } + // MARK: - Extra Feature Handlers private func handleShareLocation() { diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift index 88410b0..34417a4 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift @@ -19,6 +19,8 @@ final class ChatViewModel { private(set) var appointmentInfo: AppointmentInfo private var messages: [Chat] = [] + private var hasLoadedMessages = false + private var sharesLocationAfterLoading = false private let fetchMessagesUseCase: FetchMessagesUseCase private let sendMessageUseCase: SendMessageUseCase @@ -50,6 +52,11 @@ final class ChatViewModel { self.messages = messages self.displayItems = self.buildDisplayItems(from: messages) } + self.hasLoadedMessages = true + if self.sharesLocationAfterLoading { + self.sharesLocationAfterLoading = false + self.shareCurrentLocation() + } } } } @@ -102,6 +109,22 @@ final class ChatViewModel { } } + /// 라이브 액티비티의 위치 공유 버튼으로 들어온 경우 — 확인 없이 바로 보낸다. + /// 메시지 조회 결과가 나중에 도착하면 보낸 메시지를 덮어쓰므로, 조회가 끝난 뒤에 보낸다 + func shareCurrentLocationAfterLoading() { + if hasLoadedMessages { + shareCurrentLocation() + } else { + sharesLocationAfterLoading = true + } + } + + private func shareCurrentLocation() { + fetchCurrentLocation { [weak self] coordinate, _ in + self?.shareLocation(coordinate: coordinate) + } + } + // MARK: - Place Share func sharePlace(_ place: Place) { From 3fb35ca093940c5709162b823f8c4cdd71de563c Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:04:26 +0900 Subject: [PATCH 12/23] =?UTF-8?q?FEAT:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=95=A1=ED=8B=B0=EB=B9=84=ED=8B=B0=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EC=8B=A4=ED=8C=A8=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/ChatViewController.swift | 26 +++++++++++++++++++ .../Presentation/Chat/ChatViewModel.swift | 14 ++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift index b507ecc..e444b2d 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift @@ -404,6 +404,32 @@ final class ChatViewController: UIViewController { self?.sendButton.tintColor = canSend ? .label : .tertiaryLabel } .store(in: &cancellables) + + viewModel.$currentLocationShareError + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.showCurrentLocationShareFailedAlert() + } + .store(in: &cancellables) + } + + private func showCurrentLocationShareFailedAlert() { + let alert = UIAlertController( + title: "위치를 가져오지 못했어요", + message: "위치 권한을 허용하면 내 위치를 공유할 수 있어요.", + preferredStyle: .alert + ) + let openSettingsAction = UIAlertAction(title: "설정으로 이동", style: .default) { _ in + guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(settingsURL) + } + alert.addAction(UIAlertAction(title: "닫기", style: .cancel)) + alert.addAction(openSettingsAction) + // 해결 동작인 설정 이동을 앱 틴트 컬러(탭바와 같은 blue1)로 강조한다 + alert.preferredAction = openSettingsAction + alert.view.tintColor = .blue1 + present(alert, animated: true) } // MARK: - Update UI diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift index 34417a4..0f58ad0 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift @@ -17,6 +17,9 @@ final class ChatViewModel { @Published private(set) var canSend = false @Published private(set) var isEmpty = false + /// 라이브 액티비티 위치 공유 경로에서 현재 위치를 못 가져왔을 때 — 확인 창 없이 보내는 경로라 실패를 따로 알린다 + @Published private(set) var currentLocationShareError: Error? + private(set) var appointmentInfo: AppointmentInfo private var messages: [Chat] = [] private var hasLoadedMessages = false @@ -120,8 +123,15 @@ final class ChatViewModel { } private func shareCurrentLocation() { - fetchCurrentLocation { [weak self] coordinate, _ in - self?.shareLocation(coordinate: coordinate) + getCurrentLocationUseCase.execute { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success(let coordinate): + self?.shareLocation(coordinate: coordinate) + case .failure(let error): + self?.currentLocationShareError = error + } + } } } From ef92e913db0659cf88caacb9a43e79a3a366c328 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:04:34 +0900 Subject: [PATCH 13/23] =?UTF-8?q?FEAT:=20DEBUG=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=95=A1=ED=8B=B0=EB=B9=84?= =?UTF-8?q?=ED=8B=B0=20=EC=8B=9C=EC=9E=91=20=EB=A9=94=EB=89=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...KitRouteLiveActivityRepository+Start.swift | 93 +++++++++++++++++++ ...ViewController+LiveActivityDebugMenu.swift | 82 ++++++++++++++++ .../RouteCheckpointPlanner.swift | 90 ++++++++++++++++++ .../RouteLiveActivityStarting.swift | 19 ++++ .../StartRouteLiveActivityUseCase.swift | 64 +++++++++++++ .../Chat/ChatViewController.swift | 7 +- 6 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 WhereAreYou/WhereAreYou/DebugLiveActivity/ActivityKitRouteLiveActivityRepository+Start.swift create mode 100644 WhereAreYou/WhereAreYou/DebugLiveActivity/ChatViewController+LiveActivityDebugMenu.swift create mode 100644 WhereAreYou/WhereAreYou/DebugLiveActivity/RouteCheckpointPlanner.swift create mode 100644 WhereAreYou/WhereAreYou/DebugLiveActivity/RouteLiveActivityStarting.swift create mode 100644 WhereAreYou/WhereAreYou/DebugLiveActivity/StartRouteLiveActivityUseCase.swift diff --git a/WhereAreYou/WhereAreYou/DebugLiveActivity/ActivityKitRouteLiveActivityRepository+Start.swift b/WhereAreYou/WhereAreYou/DebugLiveActivity/ActivityKitRouteLiveActivityRepository+Start.swift new file mode 100644 index 0000000..6986a6c --- /dev/null +++ b/WhereAreYou/WhereAreYou/DebugLiveActivity/ActivityKitRouteLiveActivityRepository+Start.swift @@ -0,0 +1,93 @@ +// +// ActivityKitRouteLiveActivityRepository+Start.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-27. +// + +#if DEBUG +import Foundation +import ActivityKit + +/// [임시 · 3단계에서 삭제] ActivityKit으로 라이브 액티비티를 시작한다 — 도메인 모델 → 액티비티 모델 변환 포함. +/// 3단계에서는 서버가 이 변환 결과와 같은 형식으로 push-to-start 페이로드를 만든다 +extension ActivityKitRouteLiveActivityRepository: RouteLiveActivityStarting { + + func start(plan: RouteProgressPlan, status: RouteProgressStatus) async throws { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + + let attributes = Attributes(plan: plan) + do { + _ = try Activity.request( + attributes: attributes, + content: content(status: status, attributes: attributes), + pushType: nil + ) + } catch { + throw AppError.unknown(error) + } + } + +} + +// MARK: - 도메인 → 액티비티 모델 + +private extension AppointmentRouteActivityAttributes { + + init(plan: RouteProgressPlan) { + self.init( + appointmentID: plan.appointmentID, + appointmentName: plan.appointmentName, + placeName: plan.placeName, + appointmentTime: plan.appointmentTime, + participantCount: plan.participantCount, + segments: plan.segments.map { segment in + Segment( + transport: Segment.Transport(segment.transportType, vehicle: segment.transitVehicle), + destinationName: segment.destinationName, + estimatedMinutes: segment.estimatedMinutes, + lineName: segment.lineName, + lineColorHex: segment.lineColorHex, + stopCount: segment.stopCount + ) + }, + checkpoints: plan.checkpoints.map { checkpoint in + Checkpoint( + kind: Checkpoint.Kind(checkpoint.kind), + placeName: checkpoint.placeName, + scheduledTime: checkpoint.scheduledTime, + segmentIndex: checkpoint.segmentIndex + ) + } + ) + } + +} + +private extension AppointmentRouteActivityAttributes.Segment.Transport { + + init(_ transportType: TransportType, vehicle: RouteProgressSegment.TransitVehicle?) { + switch transportType { + case .walk: self = .walk + case .car: self = .car + case .transit: self = vehicle == .bus ? .bus : .subway + } + } + +} + +private extension AppointmentRouteActivityAttributes.Checkpoint.Kind { + + init(_ kind: RouteCheckpoint.Kind) { + switch kind { + case .departure: self = .departure + case .boarding: self = .boarding + case .alighting: self = .alighting + case .arrival: self = .arrival + } + } + +} +#endif diff --git a/WhereAreYou/WhereAreYou/DebugLiveActivity/ChatViewController+LiveActivityDebugMenu.swift b/WhereAreYou/WhereAreYou/DebugLiveActivity/ChatViewController+LiveActivityDebugMenu.swift new file mode 100644 index 0000000..dccb116 --- /dev/null +++ b/WhereAreYou/WhereAreYou/DebugLiveActivity/ChatViewController+LiveActivityDebugMenu.swift @@ -0,0 +1,82 @@ +// +// ChatViewController+LiveActivityDebugMenu.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-27. +// + +#if DEBUG +import UIKit + +/// [임시 · 3단계에서 삭제] 채팅 + 메뉴의 "라이브 액티비티 시작 (DEBUG)" — Mock 경로 시나리오와 약속 시각을 골라 시작한다. +/// +/// ⚠️ 정식 화면 코드와 다르게 ViewModel·ScreenFactory를 거치지 않고, 여기서 DIContainer로 저장소를 꺼내 유스케이스를 직접 만든다. +/// 3단계에서 폴더째 지울 코드라 ChatViewModel·ScreenFactory·DI 등록에 흔적을 남기지 않으려고 일부러 이렇게 했다. 정식 코드에서는 따라 하지 말 것 +extension ChatViewController { + + func makeLiveActivityDebugMenu(appointmentID: String) -> UIMenu { + let makeScenarioActions: (Bool) -> [UIAction] = { startsAfterAppointmentTime in + MockMyRouteScenario.allCases.map { scenario in + UIAction(title: scenario.title) { [weak self] _ in + MockAppointmentDetailRepository.myRouteScenario = scenario + MockAppointmentDetailRepository.startsAfterAppointmentTime = startsAfterAppointmentTime + self?.startLiveActivity(appointmentID: appointmentID) + } + } + } + + return UIMenu( + title: "라이브 액티비티 시작 (DEBUG)", + image: UIImage(systemName: "hammer"), + children: [ + UIMenu(title: "", options: .displayInline, children: makeScenarioActions(false)), + UIMenu( + title: "약속 시간 지난 상태로 시작", + image: UIImage(systemName: "clock.badge.exclamationmark"), + children: makeScenarioActions(true) + ), + ] + ) + } + + private func startLiveActivity(appointmentID: String) { + let container = DIContainer.shared + let useCase = StartRouteLiveActivityUseCase( + appointmentDetailRepository: container.resolve(AppointmentDetailRepository.self), + liveActivityRepository: ActivityKitRouteLiveActivityRepository(), + permissionRepository: container.resolve(LiveActivityPermissionRepository.self) + ) + + Task { @MainActor [weak self] in + do { + try await useCase.execute(appointmentID: appointmentID) + self?.showLiveActivityStartResult(error: nil) + } catch { + self?.showLiveActivityStartResult(error: error) + } + } + } + + private func showLiveActivityStartResult(error: Error?) { + let message: String + if let error { + if case AppError.permissionDenied = error { + message = "설정 > WhereAreYou에서 실시간 현황을 허용해 주세요." + } else { + message = error.localizedDescription + } + } else { + message = "잠금화면이나 다이나믹 아일랜드에서 확인하세요." + } + + let alert = UIAlertController( + title: error == nil ? "라이브 액티비티를 시작했어요" : "시작하지 못했어요", + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } + +} +#endif diff --git a/WhereAreYou/WhereAreYou/DebugLiveActivity/RouteCheckpointPlanner.swift b/WhereAreYou/WhereAreYou/DebugLiveActivity/RouteCheckpointPlanner.swift new file mode 100644 index 0000000..40d3f25 --- /dev/null +++ b/WhereAreYou/WhereAreYou/DebugLiveActivity/RouteCheckpointPlanner.swift @@ -0,0 +1,90 @@ +// +// RouteCheckpointPlanner.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +#if DEBUG +import Foundation + +/// [임시 · 3단계에서 서버로 이전] 경로를 확인 지점 목록으로 바꾼다 — 탑승 수단 기준으로 출발 → 첫 탑승역 → 각 하차역 → 도착. +/// 도보·자동차만 있는 경로는 출발 → 도착만 남는다. +/// push-to-start는 앱 코드를 거치지 않으므로 3단계에서는 서버(Functions)가 이 규칙으로 액티비티 데이터를 만든다. 서버 구현의 원본으로 쓴다 +enum RouteCheckpointPlanner { + + static func checkpoints(for route: Route, destinationName: String) -> [RouteCheckpoint] { + let steps = route.step + guard let firstStep = steps.first else { + return [ + RouteCheckpoint(kind: .departure, placeName: "", scheduledTime: route.departureTime, segmentIndex: 0), + RouteCheckpoint(kind: .arrival, placeName: destinationName, scheduledTime: route.arrivalTime, segmentIndex: 0), + ] + } + + // 대중교통이 없으면 출발 후 도착까지 묻는 지점이 없다. 주차장까지 걷는 첫 도보 대신 자동차 구간을 현재 구간으로 둔다 + let hasTransit = steps.contains { $0.transportType == .transit } + let segmentIndexAfterDeparture = hasTransit ? 0 : (steps.firstIndex { $0.transportType == .car } ?? 0) + + var checkpoints = [ + RouteCheckpoint( + kind: .departure, + placeName: firstStep.departurePoint.name, + scheduledTime: route.departureTime, + segmentIndex: segmentIndexAfterDeparture + ) + ] + + var elapsedMinutes: Double = 0 + var hasBoarded = false + + for (index, step) in steps.enumerated() { + let startTime = route.departureTime.addingTimeInterval(elapsedMinutes * 60) + elapsedMinutes += step.estimatedTime + + guard step.transportType == .transit else { continue } + + // 첫 구간부터 대중교통이면 출발이 곧 탑승이라 따로 묻지 않는다 + if !hasBoarded, index > 0 { + checkpoints.append(RouteCheckpoint( + kind: .boarding, + placeName: step.departurePoint.name, + scheduledTime: startTime, + segmentIndex: index + )) + } + hasBoarded = true + + // 마지막 구간의 하차역은 도착과 같으므로 도착으로만 묻는다 + guard index < steps.count - 1 else { continue } + checkpoints.append(RouteCheckpoint( + kind: .alighting, + placeName: step.destination.name, + scheduledTime: route.departureTime.addingTimeInterval(elapsedMinutes * 60), + segmentIndex: segmentIndexAfterAlighting(at: index, in: steps) + )) + } + + checkpoints.append(RouteCheckpoint( + kind: .arrival, + placeName: destinationName, + scheduledTime: route.arrivalTime, + segmentIndex: steps.count + )) + return checkpoints + } + + /// 하차 후 환승 도보를 거쳐 다시 대중교통을 타면, 환승 도보를 건너뛰고 다음 노선 구간을 현재 구간으로 본다. + /// 환승 탑승은 따로 묻지 않으므로 다음 하차역까지의 안내가 더 쓸모 있다 + private static func segmentIndexAfterAlighting(at index: Int, in steps: [RouteStep]) -> Int { + let nextIndex = index + 1 + let afterNextIndex = index + 2 + guard afterNextIndex < steps.count, + steps[nextIndex].transportType == .walk, + steps[afterNextIndex].transportType == .transit + else { return nextIndex } + return afterNextIndex + } + +} +#endif diff --git a/WhereAreYou/WhereAreYou/DebugLiveActivity/RouteLiveActivityStarting.swift b/WhereAreYou/WhereAreYou/DebugLiveActivity/RouteLiveActivityStarting.swift new file mode 100644 index 0000000..5f4b934 --- /dev/null +++ b/WhereAreYou/WhereAreYou/DebugLiveActivity/RouteLiveActivityStarting.swift @@ -0,0 +1,19 @@ +// +// RouteLiveActivityStarting.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-27. +// + +#if DEBUG +import Foundation + +/// [임시 · 3단계에서 삭제] 앱에서 약속 이동 라이브 액티비티를 직접 시작한다. +/// 3단계에서는 서버 push-to-start가 시작을 맡으므로, 정식 저장소(RouteLiveActivityRepository)와 분리해 이 폴더째 지울 수 있게 했다 +protocol RouteLiveActivityStarting { + + /// 떠 있는 약속 이동 액티비티를 모두 종료하고 새로 시작한다 + func start(plan: RouteProgressPlan, status: RouteProgressStatus) async throws + +} +#endif diff --git a/WhereAreYou/WhereAreYou/DebugLiveActivity/StartRouteLiveActivityUseCase.swift b/WhereAreYou/WhereAreYou/DebugLiveActivity/StartRouteLiveActivityUseCase.swift new file mode 100644 index 0000000..c609ce0 --- /dev/null +++ b/WhereAreYou/WhereAreYou/DebugLiveActivity/StartRouteLiveActivityUseCase.swift @@ -0,0 +1,64 @@ +// +// StartRouteLiveActivityUseCase.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-26. +// + +#if DEBUG +import Foundation + +/// [임시 · 3단계에서 삭제] 약속과 내 경로로 확인 지점을 만들어 라이브 액티비티를 시작한다. +/// 3단계에서는 서버가 같은 일을 해서 push-to-start로 보낸다. 그 전까지 DEBUG 메뉴에서만 쓴다. +/// 실시간 현황이 꺼져 있으면 permissionDenied, 내 경로나 약속 장소가 없으면 notFound +final class StartRouteLiveActivityUseCase { + + private let appointmentDetailRepository: AppointmentDetailRepository + private let liveActivityRepository: RouteLiveActivityStarting + private let permissionRepository: LiveActivityPermissionRepository + + init( + appointmentDetailRepository: AppointmentDetailRepository, + liveActivityRepository: RouteLiveActivityStarting, + permissionRepository: LiveActivityPermissionRepository + ) { + self.appointmentDetailRepository = appointmentDetailRepository + self.liveActivityRepository = liveActivityRepository + self.permissionRepository = permissionRepository + } + + func execute(appointmentID: String) async throws { + guard permissionRepository.authorizationStatus == .allowed else { + throw AppError.permissionDenied + } + + let (appointment, currentUserID) = try await fetchAppointment(appointmentID: appointmentID) + guard let route = appointment.routes[currentUserID], let place = appointment.place else { + throw AppError.notFound + } + + let plan = RouteProgressPlan( + appointmentID: appointment.id, + appointmentName: appointment.name, + placeName: place.name, + appointmentTime: appointment.dateTime, + participantCount: appointment.participants.count, + segments: route.step.map(RouteProgressSegment.init(step:)), + checkpoints: RouteCheckpointPlanner.checkpoints(for: route, destinationName: place.name) + ) + try await liveActivityRepository.start( + plan: plan, + status: RouteProgressStatus(reachedCheckpointCount: 0, arrivedParticipantCount: 0) + ) + } + + private func fetchAppointment(appointmentID: String) async throws -> (Appointment, String) { + try await withCheckedThrowingContinuation { continuation in + appointmentDetailRepository.fetchAppointment(appointmentID: appointmentID) { result in + continuation.resume(with: result.map { ($0.appointment, $0.currentUserID) }) + } + } + } + +} +#endif diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift index e444b2d..9caecdd 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift @@ -296,7 +296,12 @@ final class ChatViewController: UIViewController { self?.handleViewSharedPlaces() } - return UIMenu(children: [viewSharedPlaces, searchPlace, shareLocation]) + var children: [UIMenuElement] = [viewSharedPlaces, searchPlace, shareLocation] + #if DEBUG + // [임시 · 3단계에서 삭제] 라이브 액티비티 시작 메뉴 — DebugLiveActivity 폴더 + children.insert(makeLiveActivityDebugMenu(appointmentID: viewModel.appointmentInfo.id), at: 0) + #endif + return UIMenu(children: children) } /// 라이브 액티비티 위치 공유 버튼으로 들어온 경우 호출 — push 전에 불러도 된다 From 0ef5d56ec4334bd9e178b56e205038df8009da4f Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:04:34 +0900 Subject: [PATCH 14/23] =?UTF-8?q?REFACTOR:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=9C=84=EC=B9=98=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=ED=96=89=20=EA=B5=AC=EB=8F=85=20=EB=B0=A9=EC=8B=9D=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/MyPage/MyPageViewController.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift index 29e7108..817f24a 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift @@ -217,7 +217,6 @@ final class MyPageViewController: UIViewController { profileRow.value = profile.nickname profileRow.setProfileIcon(profile.profileImageName) locationSharingRow.value = profile.locationSharingOption.title - locationPermissionRow.value = viewModel.locationPermissionState.title notificationSwitch.isOn = profile.isNotificationEnabled notificationRow.value = profile.isNotificationEnabled ? "전체 알림 켜짐" : "전체 알림 꺼짐" @@ -252,6 +251,13 @@ final class MyPageViewController: UIViewController { } private func bindViewModel() { + viewModel.$locationPermissionState + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.locationPermissionRow.value = state.title + } + .store(in: &cancellables) + viewModel.$liveActivityPermissionState .receive(on: DispatchQueue.main) .sink { [weak self] state in From df86d59caf2fba5a9d999de2d8508ea473fbf067 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Sun, 27 Sep 2026 01:04:34 +0900 Subject: [PATCH 15/23] =?UTF-8?q?REMOVE:=20MyPageViewModel=20=EB=AF=B8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EC=9C=84=EC=B9=98=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=C2=B7=EA=B5=AC=EB=8F=85=20=EB=B3=B4=EA=B4=80?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/MyPage/MyPageViewModel.swift | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift index d9292af..ec410a7 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift @@ -28,7 +28,6 @@ final class MyPageViewModel { private let observeLocationPermissionUseCase: ObserveLocationPermissionUseCase private let observeLiveActivityPermissionUseCase: ObserveLiveActivityPermissionUseCase private let signOutUseCase: SignOutUseCase - private var cancellables = Set() init( profile: MyPageProfile? = nil, @@ -167,23 +166,6 @@ extension MyPageViewModel { extension MyPageViewModel { - enum LocationPermissionAction { - case requested - case shouldOpenSettings - } - - func requestLocationPermission() { - observeLocationPermissionUseCase.requestAuthorization() - } - - func handlePermissionAction() -> LocationPermissionAction { - if locationPermissionState == .notDetermined { - requestLocationPermission() - return .requested - } - return .shouldOpenSettings - } - private func bindLocationPermission() { observeLocationPermissionUseCase.statusPublisher .map(LocationPermissionState.init) From 24ed44f0aff69d614964d14375b1b2e9ae293ca3 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Mon, 28 Sep 2026 01:46:54 +0900 Subject: [PATCH 16/23] =?UTF-8?q?REFACTOR:=20=ED=94=84=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppointmentRouteLiveActivity.swift | 68 ------------------- 1 file changed, 68 deletions(-) diff --git a/WhereAreYou/WhereAreYouWidget/AppointmentRouteLiveActivity.swift b/WhereAreYou/WhereAreYouWidget/AppointmentRouteLiveActivity.swift index 02c9d2a..a101f23 100644 --- a/WhereAreYou/WhereAreYouWidget/AppointmentRouteLiveActivity.swift +++ b/WhereAreYou/WhereAreYouWidget/AppointmentRouteLiveActivity.swift @@ -231,71 +231,3 @@ struct CompactSegmentIcon: View { } } - -// MARK: - Preview - -#if DEBUG -private extension AppointmentRouteActivityAttributes { - - static var preview: AppointmentRouteActivityAttributes { - makePreview(departure: Date().addingTimeInterval(60)) - } - - /// 약속 시각이 5분 지난 상태 - static var previewPastAppointment: AppointmentRouteActivityAttributes { - makePreview(departure: Date().addingTimeInterval(-29 * 60)) - } - - private static func makePreview(departure: Date) -> AppointmentRouteActivityAttributes { - AppointmentRouteActivityAttributes( - appointmentID: "preview", - appointmentName: "고기굽는방앗간 이수역점", - placeName: "스타벅스 강남역점", - appointmentTime: departure.addingTimeInterval(24 * 60), - participantCount: 6, - segments: [ - Segment(transport: .walk, destinationName: "이수역", estimatedMinutes: 6, lineName: nil, lineColorHex: nil, stopCount: nil), - Segment(transport: .subway, destinationName: "사당역", estimatedMinutes: 2, lineName: "4호선", lineColorHex: "#00A5DE", stopCount: 1), - Segment(transport: .walk, destinationName: "사당역", estimatedMinutes: 3, lineName: nil, lineColorHex: nil, stopCount: nil), - Segment(transport: .subway, destinationName: "강남역", estimatedMinutes: 8, lineName: "2호선", lineColorHex: "#00A84D", stopCount: 4), - Segment(transport: .walk, destinationName: "스타벅스 강남역점", estimatedMinutes: 4, lineName: nil, lineColorHex: nil, stopCount: nil), - ], - checkpoints: [ - Checkpoint(kind: .departure, placeName: "출발지", scheduledTime: departure, segmentIndex: 0), - Checkpoint(kind: .boarding, placeName: "이수역", scheduledTime: departure.addingTimeInterval(6 * 60), segmentIndex: 1), - Checkpoint(kind: .alighting, placeName: "사당역", scheduledTime: departure.addingTimeInterval(8 * 60), segmentIndex: 3), - Checkpoint(kind: .alighting, placeName: "강남역", scheduledTime: departure.addingTimeInterval(19 * 60), segmentIndex: 4), - Checkpoint(kind: .arrival, placeName: "스타벅스 강남역점", scheduledTime: departure.addingTimeInterval(23 * 60), segmentIndex: 5), - ] - ) - } - -} - -#Preview("잠금화면", as: .content, using: AppointmentRouteActivityAttributes.preview) { - AppointmentRouteLiveActivity() -} contentStates: { - AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 0, arrivedParticipantCount: 0) - AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 3, arrivedParticipantCount: 2) - AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 5, arrivedParticipantCount: 3) -} - -#Preview("잠금화면 (약속 시간 지남)", as: .content, using: AppointmentRouteActivityAttributes.previewPastAppointment) { - AppointmentRouteLiveActivity() -} contentStates: { - AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 3, arrivedParticipantCount: 4) -} - -#Preview("다이나믹 아일랜드 확장", as: .dynamicIsland(.expanded), using: AppointmentRouteActivityAttributes.preview) { - AppointmentRouteLiveActivity() -} contentStates: { - AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 3, arrivedParticipantCount: 2) -} - -#Preview("다이나믹 아일랜드 컴팩트", as: .dynamicIsland(.compact), using: AppointmentRouteActivityAttributes.preview) { - AppointmentRouteLiveActivity() -} contentStates: { - AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 0, arrivedParticipantCount: 0) - AppointmentRouteActivityAttributes.ContentState(reachedCheckpointCount: 3, arrivedParticipantCount: 2) -} -#endif From b364bfed5f6aedc4773d1329985b3ab44dbe842d Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Mon, 28 Sep 2026 01:47:14 +0900 Subject: [PATCH 17/23] =?UTF-8?q?DESIGN:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=98=84=ED=99=A9=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Presentation/MyPage/MyPageViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift index 817f24a..4bc8e3c 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift @@ -75,7 +75,7 @@ final class MyPageViewController: UIViewController { private let locationSharingRow = MyPageRow(icon: UIImage(systemName: "location"), title: "위치 공유 설정") private let locationPermissionRow = MyPageRow(icon: UIImage(systemName: "location.circle"), title: "위치 권한 설정") private let liveActivityPermissionRow = MyPageRow( - icon: UIImage(systemName: "rectangle.bottomhalf.filled"), + icon: UIImage(systemName: "clock.badge"), title: "실시간 현황 설정" ) From c1c5e3e3d30d995c68cd9a47c7d8c969c9c9c5ae Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Mon, 28 Sep 2026 23:21:09 +0900 Subject: [PATCH 18/23] =?UTF-8?q?REFACTOR:=20FCMTokenService=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=86=A0=EC=BD=9C=EA=B3=BC=20DefaultFCMTokenService?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppDIContainer+Register.swift | 2 +- .../Service/DefaultFCMTokenService.swift | 31 +++++++++++++++++++ .../Domain/Service/FCMTokenService.swift | 20 ++---------- 3 files changed, 35 insertions(+), 18 deletions(-) create mode 100644 WhereAreYou/WhereAreYou/Domain/Service/DefaultFCMTokenService.swift diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift index 7ef2fa5..e7d8926 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift @@ -24,7 +24,7 @@ extension DIContainer { // 푸시 알림 register(FCMTokenRepository.self, instance: FirestoreFCMTokenRepository()) - register(FCMTokenService.self, instance: FCMTokenService( + register(FCMTokenService.self, instance: DefaultFCMTokenService( authRepository: resolve(AuthRepository.self), fcmTokenRepository: resolve(FCMTokenRepository.self) )) diff --git a/WhereAreYou/WhereAreYou/Domain/Service/DefaultFCMTokenService.swift b/WhereAreYou/WhereAreYou/Domain/Service/DefaultFCMTokenService.swift new file mode 100644 index 0000000..ead76b8 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Service/DefaultFCMTokenService.swift @@ -0,0 +1,31 @@ +// +// DefaultFCMTokenService.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-28. +// + +import Foundation + +/// FCMTokenService 구현 — 인증 저장소에서 현재 유저 ID를 얻어 FCM 토큰 저장소에 저장·삭제한다 +final class DefaultFCMTokenService: FCMTokenService { + + private let authRepository: AuthRepository + private let fcmTokenRepository: FCMTokenRepository + + init(authRepository: AuthRepository, fcmTokenRepository: FCMTokenRepository) { + self.authRepository = authRepository + self.fcmTokenRepository = fcmTokenRepository + } + + func save(token: String) async throws { + guard let userID = authRepository.currentUserID else { return } + try await fcmTokenRepository.save(token: token, forUserID: userID) + } + + func deleteForCurrentUser() async throws { + guard let userID = authRepository.currentUserID else { return } + try await fcmTokenRepository.delete(forUserID: userID) + } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Service/FCMTokenService.swift b/WhereAreYou/WhereAreYou/Domain/Service/FCMTokenService.swift index 986973b..d3fffb9 100644 --- a/WhereAreYou/WhereAreYou/Domain/Service/FCMTokenService.swift +++ b/WhereAreYou/WhereAreYou/Domain/Service/FCMTokenService.swift @@ -8,24 +8,10 @@ import Foundation /// FCM 토큰 서비스 — 현재 로그인된 유저의 FCM 디바이스 토큰 저장·삭제 -final class FCMTokenService { +protocol FCMTokenService { - private let authRepository: AuthRepository - private let fcmTokenRepository: FCMTokenRepository + func save(token: String) async throws - init(authRepository: AuthRepository, fcmTokenRepository: FCMTokenRepository) { - self.authRepository = authRepository - self.fcmTokenRepository = fcmTokenRepository - } - - func save(token: String) async throws { - guard let userID = authRepository.currentUserID else { return } - try await fcmTokenRepository.save(token: token, forUserID: userID) - } - - func deleteForCurrentUser() async throws { - guard let userID = authRepository.currentUserID else { return } - try await fcmTokenRepository.delete(forUserID: userID) - } + func deleteForCurrentUser() async throws } From c834db4c6583a5b691af10428dce09bc2a09c3b8 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Mon, 28 Sep 2026 23:21:09 +0900 Subject: [PATCH 19/23] =?UTF-8?q?REFACTOR:=20SessionValidationService=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=86=A0=EC=BD=9C=EA=B3=BC=20DefaultSessionV?= =?UTF-8?q?alidationService=20=EA=B5=AC=ED=98=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppDIContainer+Register.swift | 2 +- .../DefaultSessionValidationService.swift | 37 +++++++++++++++++++ .../Service/SessionValidationService.swift | 26 ++----------- 3 files changed, 41 insertions(+), 24 deletions(-) create mode 100644 WhereAreYou/WhereAreYou/Domain/Service/DefaultSessionValidationService.swift diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift index e7d8926..80b3e71 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift @@ -30,7 +30,7 @@ extension DIContainer { )) // 세션 검증 - register(SessionValidationService.self, instance: SessionValidationService( + register(SessionValidationService.self, instance: DefaultSessionValidationService( authRepository: resolve(AuthRepository.self), userRepository: resolve(UserRepository.self) )) diff --git a/WhereAreYou/WhereAreYou/Domain/Service/DefaultSessionValidationService.swift b/WhereAreYou/WhereAreYou/Domain/Service/DefaultSessionValidationService.swift new file mode 100644 index 0000000..8da4ab1 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Service/DefaultSessionValidationService.swift @@ -0,0 +1,37 @@ +// +// DefaultSessionValidationService.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-28. +// + +import Foundation + +/// SessionValidationService 구현 — 인증 저장소의 현재 유저를 유저 저장소에서 다시 조회해 유효성을 판단한다 +final class DefaultSessionValidationService: SessionValidationService { + + private let authRepository: AuthRepository + private let userRepository: UserRepository + + init(authRepository: AuthRepository, userRepository: UserRepository) { + self.authRepository = authRepository + self.userRepository = userRepository + } + + var currentUserID: String? { + authRepository.currentUserID + } + + func validate() async -> SessionValidationResult { + guard let userID = authRepository.currentUserID else { return .invalid } + do { + _ = try await userRepository.fetchUser(userID: userID) + return .valid + } catch AppError.network { + return .networkError + } catch { + return .invalid + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Service/SessionValidationService.swift b/WhereAreYou/WhereAreYou/Domain/Service/SessionValidationService.swift index 358ebcc..97ad855 100644 --- a/WhereAreYou/WhereAreYou/Domain/Service/SessionValidationService.swift +++ b/WhereAreYou/WhereAreYou/Domain/Service/SessionValidationService.swift @@ -15,30 +15,10 @@ enum SessionValidationResult { } /// 세션 검증 서비스 — 현재 로그인된 유저가 유효한지 확인 -final class SessionValidationService { +protocol SessionValidationService { - private let authRepository: AuthRepository - private let userRepository: UserRepository + var currentUserID: String? { get } - init(authRepository: AuthRepository, userRepository: UserRepository) { - self.authRepository = authRepository - self.userRepository = userRepository - } - - var currentUserID: String? { - authRepository.currentUserID - } - - func validate() async -> SessionValidationResult { - guard let userID = authRepository.currentUserID else { return .invalid } - do { - _ = try await userRepository.fetchUser(userID: userID) - return .valid - } catch AppError.network { - return .networkError - } catch { - return .invalid - } - } + func validate() async -> SessionValidationResult } From ef8406de7b26f3ce473b28b07b4129cb0070a819 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Mon, 28 Sep 2026 23:21:20 +0900 Subject: [PATCH 20/23] =?UTF-8?q?REFACTOR:=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=9D=B8=ED=85=90=ED=8A=B8=EC=9D=98=20Rou?= =?UTF-8?q?teProgressService=EB=A5=BC=20AppDependency=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Shared/AdvanceRouteProgressIntent.swift | 10 +++++----- WhereAreYou/Shared/RouteProgressService.swift | 18 ++++++++++++++++++ .../WhereAreYou.xcodeproj/project.pbxproj | 2 -- WhereAreYou/WhereAreYou/Core/AppDelegate.swift | 9 +++++++++ .../AppDIContainer+Register.swift | 4 ++-- ...swift => DefaultRouteProgressService.swift} | 14 +++++++++----- 6 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 WhereAreYou/Shared/RouteProgressService.swift rename WhereAreYou/WhereAreYou/Domain/Service/{RouteProgressService.swift => DefaultRouteProgressService.swift} (77%) diff --git a/WhereAreYou/Shared/AdvanceRouteProgressIntent.swift b/WhereAreYou/Shared/AdvanceRouteProgressIntent.swift index df8eb5d..8c4f79f 100644 --- a/WhereAreYou/Shared/AdvanceRouteProgressIntent.swift +++ b/WhereAreYou/Shared/AdvanceRouteProgressIntent.swift @@ -9,7 +9,7 @@ import Foundation import AppIntents /// 라이브 액티비티의 단계 버튼("출발했나요?" 등) — 누르면 액티비티를 다음 확인 지점으로 넘긴다. -/// LiveActivityIntent는 앱 프로세스에서 실행되므로 실제 처리는 앱 타깃에서만 컴파일된다 +/// LiveActivityIntent라 앱 프로세스에서 실행된다. 처리는 앱이 AppDependencyManager에 등록한 RouteProgressService에 맡긴다 struct AdvanceRouteProgressIntent: LiveActivityIntent { static var title: LocalizedStringResource = "이동 단계 진행" @@ -18,6 +18,9 @@ struct AdvanceRouteProgressIntent: LiveActivityIntent { @Parameter(title: "약속 ID") var appointmentID: String + @AppDependency + private var routeProgressService: RouteProgressService + init() { } init(appointmentID: String) { @@ -25,10 +28,7 @@ struct AdvanceRouteProgressIntent: LiveActivityIntent { } func perform() async throws -> some IntentResult { - #if !WIDGET_EXTENSION - let service = await DIContainer.shared.resolve(RouteProgressService.self) - await service.advance(appointmentID: appointmentID) - #endif + await routeProgressService.advance(appointmentID: appointmentID) return .result() } diff --git a/WhereAreYou/Shared/RouteProgressService.swift b/WhereAreYou/Shared/RouteProgressService.swift new file mode 100644 index 0000000..6744c02 --- /dev/null +++ b/WhereAreYou/Shared/RouteProgressService.swift @@ -0,0 +1,18 @@ +// +// RouteProgressService.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-28. +// + +import Foundation + +/// 약속 이동 진행 서비스 — 라이브 액티비티 단계 버튼(LiveActivityIntent)이 화면 없이 부른다. +/// 인텐트가 이 프로토콜만 알도록 앱·위젯 공용 폴더에 둔다. 구현(DefaultRouteProgressService)은 앱 타깃에만 있고, +/// 앱 시작 시 AppDependencyManager에 등록해 인텐트의 @AppDependency로 주입한다 +protocol RouteProgressService: Sendable { + + /// 다음 확인 지점으로 넘긴다. 마지막 지점이면 액티비티를 종료한다 + func advance(appointmentID: String) async + +} diff --git a/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj b/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj index b6007d7..12df61c 100644 --- a/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj +++ b/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj @@ -524,7 +524,6 @@ STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION"; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; @@ -561,7 +560,6 @@ STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION"; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift index 1160fae..4687732 100644 --- a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift @@ -14,6 +14,7 @@ import FirebaseDatabase import FirebaseStorage import FirebaseMessaging import UserNotifications +import AppIntents @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -28,6 +29,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { clearKeychainOnReinstall() configureFirebaseEmulators() DIContainer.shared.registerDependencies() + registerAppIntentDependencies() UNUserNotificationCenter.current().delegate = self Messaging.messaging().delegate = self @@ -35,6 +37,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + /// 라이브 액티비티 버튼 같은 App Intent가 @AppDependency로 받을 의존성을 등록한다. + /// 인텐트는 앱이 실행된 직후 이 등록을 거쳐 실행되므로, DI 컨테이너를 직접 알 필요가 없다 + private func registerAppIntentDependencies() { + let routeProgressService: RouteProgressService = DIContainer.shared.resolve() + AppDependencyManager.shared.add(dependency: routeProgressService) + } + /// 앱 재설치 시 Keychain에 남은 Firebase Auth 인증 정보를 제거 private func clearKeychainOnReinstall() { let hasLaunchedKey = "hasLaunchedBefore" diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift index 80b3e71..3fa81a1 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift @@ -66,8 +66,8 @@ extension DIContainer { // 약속 이동 라이브 액티비티 register(RouteLiveActivityRepository.self, instance: ActivityKitRouteLiveActivityRepository()) register(LiveActivityPermissionRepository.self, instance: ActivityKitLiveActivityPermissionRepository()) - // 단계 버튼(LiveActivityIntent)이 앱 프로세스에서 이 서비스를 꺼내 쓴다 - register(RouteProgressService.self, instance: RouteProgressService( + // 단계 버튼(LiveActivityIntent)은 AppDelegate가 AppDependencyManager에 넘긴 이 인스턴스를 쓴다 + register(RouteProgressService.self, instance: DefaultRouteProgressService( liveActivityRepository: resolve(RouteLiveActivityRepository.self) )) } diff --git a/WhereAreYou/WhereAreYou/Domain/Service/RouteProgressService.swift b/WhereAreYou/WhereAreYou/Domain/Service/DefaultRouteProgressService.swift similarity index 77% rename from WhereAreYou/WhereAreYou/Domain/Service/RouteProgressService.swift rename to WhereAreYou/WhereAreYou/Domain/Service/DefaultRouteProgressService.swift index 98d2f72..1ad45ba 100644 --- a/WhereAreYou/WhereAreYou/Domain/Service/RouteProgressService.swift +++ b/WhereAreYou/WhereAreYou/Domain/Service/DefaultRouteProgressService.swift @@ -1,5 +1,5 @@ // -// RouteProgressService.swift +// DefaultRouteProgressService.swift // WhereAreYou // // Created by 이상유 on 2026-09-27. @@ -7,10 +7,10 @@ import Foundation -/// 약속 이동 진행 처리 — 라이브 액티비티 단계 버튼(LiveActivityIntent)이 화면 없이 부르므로 DI에 등록해 쓴다. -/// 다음 확인 지점으로 넘기고, 도착이면 종료한다. 약속 시간 전에 도착하면 약속 시간까지, 지나서 도착하면 1분 뒤까지 화면에 남긴다. +/// RouteProgressService 구현 — 다음 확인 지점으로 넘기고, 도착이면 종료한다. +/// 약속 시간 전에 도착하면 약속 시간까지, 지나서 도착하면 1분 뒤까지 화면에 남긴다. /// 도달 단계는 따로 저장하지 않고 액티비티 상태에만 둔다. 진행 중인 액티비티가 없거나 이미 도착했으면 아무것도 하지 않는다 -final class RouteProgressService { +final class DefaultRouteProgressService: RouteProgressService { /// 약속 시간이 지나 도착했을 때 화면에 남겨 두는 시간 static let dismissalDelayAfterLateArrival: TimeInterval = 60 @@ -21,7 +21,11 @@ final class RouteProgressService { self.liveActivityRepository = liveActivityRepository } - func advance(appointmentID: String, now: Date = Date()) async { + func advance(appointmentID: String) async { + await advance(appointmentID: appointmentID, now: Date()) + } + + func advance(appointmentID: String, now: Date) async { guard let (plan, currentStatus) = liveActivityRepository.current(appointmentID: appointmentID) else { return } let reachedIndex = currentStatus.reachedCheckpointCount From 5bbc61e3b8c881980973c6d76d5c977a44da55d4 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Mon, 28 Sep 2026 23:21:20 +0900 Subject: [PATCH 21/23] =?UTF-8?q?FIX:=20=EB=A7=88=EC=A7=80=EB=A7=89=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EC=A7=80=EC=A0=90=EC=9D=B4=20=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=20=EC=A7=80=EC=A0=90=EC=9D=B4=20=EC=95=84=EB=8B=90=20?= =?UTF-8?q?=EB=95=8C=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=95=A1=ED=8B=B0?= =?UTF-8?q?=EB=B9=84=ED=8B=B0=20=EB=AF=B8=EC=A2=85=EB=A3=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Service/DefaultRouteProgressService.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Domain/Service/DefaultRouteProgressService.swift b/WhereAreYou/WhereAreYou/Domain/Service/DefaultRouteProgressService.swift index 1ad45ba..c2ef6c2 100644 --- a/WhereAreYou/WhereAreYou/Domain/Service/DefaultRouteProgressService.swift +++ b/WhereAreYou/WhereAreYou/Domain/Service/DefaultRouteProgressService.swift @@ -7,7 +7,7 @@ import Foundation -/// RouteProgressService 구현 — 다음 확인 지점으로 넘기고, 도착이면 종료한다. +/// RouteProgressService 구현 — 다음 확인 지점으로 넘기고, 마지막 지점이면 종료한다. /// 약속 시간 전에 도착하면 약속 시간까지, 지나서 도착하면 1분 뒤까지 화면에 남긴다. /// 도달 단계는 따로 저장하지 않고 액티비티 상태에만 둔다. 진행 중인 액티비티가 없거나 이미 도착했으면 아무것도 하지 않는다 final class DefaultRouteProgressService: RouteProgressService { @@ -32,7 +32,8 @@ final class DefaultRouteProgressService: RouteProgressService { guard plan.checkpoints.indices.contains(reachedIndex) else { return } // TODO: 채팅방에 도착 단계 메시지 전송 + 약속 지도에 내 위치 좌표 갱신 (약속·채팅 Firestore 전환 후) - let isArrival = plan.checkpoints[reachedIndex].kind == .arrival + // 도착 지점은 항상 마지막이어야 한다. 데이터가 어긋나도 액티비티가 끝나지 않고 남는 일이 없도록 마지막 인덱스도 도착으로 본다 + let isArrival = plan.checkpoints[reachedIndex].kind == .arrival || reachedIndex == plan.checkpoints.count - 1 var status = currentStatus status.reachedCheckpointCount = reachedIndex + 1 if isArrival { From 271b4fe189a59e8a01b9d79e5c8bef35351e0214 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Mon, 28 Sep 2026 23:21:20 +0900 Subject: [PATCH 22/23] =?UTF-8?q?REFACTOR:=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EB=B0=94=20ForEach=EC=9D=98=20=EC=A4=91=EA=B0=84=20=EB=B0=B0?= =?UTF-8?q?=EC=97=B4=20=ED=95=A0=EB=8B=B9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WhereAreYou/WhereAreYouWidget/RouteProgressBar.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/WhereAreYou/WhereAreYouWidget/RouteProgressBar.swift b/WhereAreYou/WhereAreYouWidget/RouteProgressBar.swift index 0434d2d..dec041b 100644 --- a/WhereAreYou/WhereAreYouWidget/RouteProgressBar.swift +++ b/WhereAreYou/WhereAreYouWidget/RouteProgressBar.swift @@ -16,7 +16,8 @@ struct RouteProgressBar: View { var body: some View { HStack(spacing: 4) { - ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in + ForEach(segments.indices, id: \.self) { index in + let segment = segments[index] if index > 0 { Capsule() .fill(isPassed(index) || isCurrent(index) ? WidgetPalette.accent : Color.secondary.opacity(0.3)) From 5bad620041a4f6970065103fa6ab4bfe6e449873 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Thu, 1 Oct 2026 20:25:16 +0900 Subject: [PATCH 23/23] =?UTF-8?q?FIX:=20=EA=B0=99=EC=9D=80=20=EC=95=BD?= =?UTF-8?q?=EC=86=8D=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=95=A1=ED=8B=B0?= =?UTF-8?q?=EB=B9=84=ED=8B=B0=20=EB=94=A5=EB=A7=81=ED=81=AC=EB=A1=9C=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EC=9E=85=EB=A0=A5=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EA=B0=80=20=EC=82=AC=EB=9D=BC=EC=A7=80=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WhereAreYou/Shared/AppointmentDeepLink.swift | 7 +++ .../AppointmentListCoordinator.swift | 44 ++++++++++++++++--- .../Implementation/ChatCoordinator.swift | 41 +++++++++++++++++ .../Implementation/HomeCoordinator.swift | 29 ++++++++++-- .../Implementation/TabBarCoordinator.swift | 36 ++++++++------- .../AppointmentDeepLinkHandling.swift | 18 ++++++++ .../Protocol/NavigationCoordinator.swift | 9 ++++ 7 files changed, 159 insertions(+), 25 deletions(-) create mode 100644 WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/AppointmentDeepLinkHandling.swift diff --git a/WhereAreYou/Shared/AppointmentDeepLink.swift b/WhereAreYou/Shared/AppointmentDeepLink.swift index c442a38..ba44566 100644 --- a/WhereAreYou/Shared/AppointmentDeepLink.swift +++ b/WhereAreYou/Shared/AppointmentDeepLink.swift @@ -16,6 +16,13 @@ enum AppointmentDeepLink { /// 채팅방을 열고 내 위치를 바로 전송 case shareLocation(appointmentID: String) + var appointmentID: String { + switch self { + case .route(let appointmentID), .shareLocation(let appointmentID): + return appointmentID + } + } + static let scheme = "whereareyou" private static let host = "appointment" private static let routePath = "route" diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppointmentListCoordinator.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppointmentListCoordinator.swift index 2572e7f..305161c 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppointmentListCoordinator.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppointmentListCoordinator.swift @@ -13,6 +13,9 @@ final class AppointmentListCoordinator: NavigationCoordinator, AppointmentListCo private let screenFactory: AppointmentListScreenFactory private var chatCoordinator: ChatCoordinator? private var routeSearchCoordinator: RouteSearchCoordinator? + /// 목록에서 띄운 지도와 그 약속 — 같은 약속 지도가 이미 떠 있는지 확인하려고 둔다 + private weak var routeViewController: AppointmentRouteViewController? + private var routeAppointmentID: String? init( navigationController: UINavigationController, @@ -42,15 +45,14 @@ final class AppointmentListCoordinator: NavigationCoordinator, AppointmentListCo showChat(appointmentID: appointmentID, sharesLocationImmediately: false) } - /// 라이브 액티비티의 위치 공유 버튼 — 채팅방을 열면서 확인 없이 내 위치를 바로 보낸다 - func showChatSharingLocation(appointmentID: String) { - showChat(appointmentID: appointmentID, sharesLocationImmediately: true) - } - private func showChat(appointmentID: String, sharesLocationImmediately: Bool) { screenFactory.makeChatViewController(appointmentID: appointmentID) { [weak self] result in guard let self, case .success(let chatViewController) = result else { return } - let coordinator = ChatCoordinator(navigationController: self.navigationController) + let coordinator = ChatCoordinator( + navigationController: self.navigationController, + appointmentID: appointmentID, + chatViewController: chatViewController + ) self.chatCoordinator = coordinator chatViewController.coordinator = coordinator if sharesLocationImmediately { @@ -65,6 +67,8 @@ final class AppointmentListCoordinator: NavigationCoordinator, AppointmentListCo func showAppointmentRoute(appointmentID: String) { let viewController = screenFactory.makeAppointmentRouteViewController(appointmentID: appointmentID) viewController.coordinator = self + routeViewController = viewController + routeAppointmentID = appointmentID present(viewController) } @@ -75,3 +79,31 @@ final class AppointmentListCoordinator: NavigationCoordinator, AppointmentListCo } } + +// MARK: - 라이브 액티비티 딥링크 + +extension AppointmentListCoordinator: AppointmentDeepLinkHandling { + + /// 목록에서 띄운 같은 약속 지도가 이미 떠 있으면 그대로 두고, 아니면 이 탭에서 연 채팅에 맡긴다 + func handleInPlace(_ deepLink: AppointmentDeepLink) -> Bool { + if case .route = deepLink, + routeAppointmentID == deepLink.appointmentID, + routeViewController?.presentingViewController != nil { + return true + } + return chatCoordinator?.handleInPlace(deepLink) ?? false + } + + /// 지금 화면에서 처리할 수 없는 딥링크 — 이 탭의 스택을 비우고 목적지를 새로 띄운다 + func showFromDeepLink(_ deepLink: AppointmentDeepLink) { + navigationController.popToRootViewController(animated: false) + switch deepLink { + case .route(let appointmentID): + showAppointmentRoute(appointmentID: appointmentID) + case .shareLocation(let appointmentID): + // 라이브 액티비티의 위치 공유 버튼 — 채팅방을 열면서 확인 없이 내 위치를 바로 보낸다 + showChat(appointmentID: appointmentID, sharesLocationImmediately: true) + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/ChatCoordinator.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/ChatCoordinator.swift index b41c131..ff8dab3 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/ChatCoordinator.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/ChatCoordinator.swift @@ -13,11 +13,22 @@ final class ChatCoordinator: NavigationCoordinator, DatePickerPresenting, PlaceS private let screenFactory: ChatScreenFactory private var routeSearchCoordinator: RouteSearchCoordinator? + /// 이 코디네이터가 맡은 채팅의 약속 — 딥링크가 같은 약속인지 판단할 때 쓴다 + private let appointmentID: String + /// 채팅이 지금 화면 맨 위에 있는지 확인하려고 둔다. 소유는 내비게이션 스택이 한다 + private weak var chatViewController: ChatViewController? + /// 같은 약속 지도가 이미 떠 있는지 확인하려고 둔다 + private weak var routeViewController: AppointmentRouteViewController? + init( navigationController: UINavigationController, + appointmentID: String, + chatViewController: ChatViewController, screenFactory: ScreenFactory = .shared ) { self.navigationController = navigationController + self.appointmentID = appointmentID + self.chatViewController = chatViewController self.screenFactory = screenFactory } @@ -31,6 +42,7 @@ final class ChatCoordinator: NavigationCoordinator, DatePickerPresenting, PlaceS func showAppointmentRoute(appointmentID: String) { let viewController = screenFactory.makeAppointmentRouteViewController(appointmentID: appointmentID) viewController.coordinator = self + routeViewController = viewController present(viewController) } @@ -116,3 +128,32 @@ final class ChatCoordinator: NavigationCoordinator, DatePickerPresenting, PlaceS } } + +// MARK: - 라이브 액티비티 딥링크 + +extension ChatCoordinator: AppointmentDeepLinkHandling { + + /// 내 채팅이 맨 위에 있고 같은 약속일 때만 처리한다 — 채팅을 다시 만들지 않아 입력 중이던 내용이 유지된다 + func handleInPlace(_ deepLink: AppointmentDeepLink) -> Bool { + guard deepLink.appointmentID == appointmentID, + let chatViewController, + navigationController.topViewController === chatViewController + else { return false } + + switch deepLink { + case .route: + // 같은 약속 지도가 이미 떠 있으면 그대로 둔다 + guard routeViewController?.presentingViewController == nil else { return true } + dismissPresented { [weak self] in + guard let self else { return } + self.showAppointmentRoute(appointmentID: self.appointmentID) + } + case .shareLocation: + dismissPresented { + chatViewController.shareCurrentLocationAfterLoading() + } + } + return true + } + +} diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/HomeCoordinator.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/HomeCoordinator.swift index 0ece116..63a0817 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/HomeCoordinator.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/HomeCoordinator.swift @@ -41,7 +41,11 @@ final class HomeCoordinator: NavigationCoordinator, DatePickerPresenting, PlaceS withChatFor appointmentInfo: AppointmentInfo ) { let chatViewController = screenFactory.makeChatViewController(appointmentInfo: appointmentInfo) - let coordinator = ChatCoordinator(navigationController: navigationController) + let coordinator = ChatCoordinator( + navigationController: navigationController, + appointmentID: appointmentInfo.id, + chatViewController: chatViewController + ) chatCoordinator = coordinator chatViewController.coordinator = coordinator @@ -59,7 +63,11 @@ final class HomeCoordinator: NavigationCoordinator, DatePickerPresenting, PlaceS func showChat(appointmentID: String) { screenFactory.makeChatViewController(appointmentID: appointmentID) { [weak self] result in guard let self, case .success(let chatViewController) = result else { return } - let coordinator = ChatCoordinator(navigationController: self.navigationController) + let coordinator = ChatCoordinator( + navigationController: self.navigationController, + appointmentID: appointmentID, + chatViewController: chatViewController + ) self.chatCoordinator = coordinator chatViewController.coordinator = coordinator self.push(chatViewController) @@ -68,7 +76,11 @@ final class HomeCoordinator: NavigationCoordinator, DatePickerPresenting, PlaceS func showChat(appointmentInfo: AppointmentInfo) { let chatViewController = screenFactory.makeChatViewController(appointmentInfo: appointmentInfo) - let coordinator = ChatCoordinator(navigationController: navigationController) + let coordinator = ChatCoordinator( + navigationController: navigationController, + appointmentID: appointmentInfo.id, + chatViewController: chatViewController + ) chatCoordinator = coordinator chatViewController.coordinator = coordinator push(chatViewController) @@ -105,3 +117,14 @@ final class HomeCoordinator: NavigationCoordinator, DatePickerPresenting, PlaceS } } + +// MARK: - 라이브 액티비티 딥링크 + +extension HomeCoordinator: AppointmentDeepLinkHandling { + + /// 홈 탭에서 연 채팅이 같은 약속이면 그 채팅이 처리한다 + func handleInPlace(_ deepLink: AppointmentDeepLink) -> Bool { + chatCoordinator?.handleInPlace(deepLink) ?? false + } + +} diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift index 46dedba..01d18df 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift @@ -56,27 +56,31 @@ final class TabBarCoordinator: Coordinator { // MARK: - 딥링크 - /// 약속 탭으로 전환하고 떠 있던 모달·push 화면을 정리한 뒤 목적지 화면을 띄운다 + /// 지금 탭의 코디네이터가 자기 화면에서 처리할 수 있으면 맡기고, + /// 아니면 떠 있는 모달을 정리한 뒤 약속 탭에서 목적지를 새로 띄운다 func handle(deepLink: AppointmentDeepLink) { - guard let appointmentListCoordinator else { return } - let navigationController = appointmentListCoordinator.navigationController - tabBarController.selectedViewController = navigationController + if selectedTabCoordinator?.handleInPlace(deepLink) == true { return } - let route = { - navigationController.popToRootViewController(animated: false) - switch deepLink { - case .route(let appointmentID): - appointmentListCoordinator.showAppointmentRoute(appointmentID: appointmentID) - case .shareLocation(let appointmentID): - appointmentListCoordinator.showChatSharingLocation(appointmentID: appointmentID) - } + guard let appointmentListCoordinator else { return } + let showInAppointmentList = { [weak self] in + guard let self else { return } + self.tabBarController.selectedViewController = appointmentListCoordinator.navigationController + appointmentListCoordinator.showFromDeepLink(deepLink) } - if tabBarController.presentedViewController != nil { - tabBarController.dismiss(animated: false, completion: route) - } else { - route() + // 모든 탭의 모달은 루트인 탭바 컨트롤러 위에 뜨므로 여기서 한 번에 정리한다 + guard tabBarController.presentedViewController != nil else { + showInAppointmentList() + return } + tabBarController.dismiss(animated: false, completion: showInAppointmentList) + } + + private var selectedTabCoordinator: AppointmentDeepLinkHandling? { + let selectedViewController = tabBarController.selectedViewController + if selectedViewController === homeCoordinator?.navigationController { return homeCoordinator } + if selectedViewController === appointmentListCoordinator?.navigationController { return appointmentListCoordinator } + return nil } } diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/AppointmentDeepLinkHandling.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/AppointmentDeepLinkHandling.swift new file mode 100644 index 0000000..86516ae --- /dev/null +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/AppointmentDeepLinkHandling.swift @@ -0,0 +1,18 @@ +// +// AppointmentDeepLinkHandling.swift +// WhereAreYou +// +// Created by 이상유 on 2026-10-01. +// + +import Foundation + +/// 라이브 액티비티 딥링크를 "지금 보이는 자기 화면"에서 처리할 수 있는지 답하는 코디네이터. +/// 상위(TabBarCoordinator)는 화면 구조를 모른 채 묻기만 하고, 판단과 처리는 화면을 띄운 코디네이터가 맡는다 +protocol AppointmentDeepLinkHandling: AnyObject { + + /// 지금 보이는 자기 화면이 딥링크의 약속이면 그 자리에서 처리하고 true를 돌려준다. + /// 처리할 수 없으면 아무것도 하지 않고 false — 상위가 목적지를 새로 띄운다 + func handleInPlace(_ deepLink: AppointmentDeepLink) -> Bool + +} diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/NavigationCoordinator.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/NavigationCoordinator.swift index 741f4cc..a6fd6c3 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/NavigationCoordinator.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/NavigationCoordinator.swift @@ -39,4 +39,13 @@ extension NavigationCoordinator { navigationController.present(wrapped, animated: animated) } + /// 이 내비게이션 스택 위에 떠 있는 모달을 닫은 뒤 completion 실행. 떠 있는 모달이 없으면 바로 실행 + func dismissPresented(animated: Bool = false, then completion: @escaping () -> Void) { + guard navigationController.presentedViewController != nil else { + completion() + return + } + navigationController.dismiss(animated: animated, completion: completion) + } + }