From 0a5501ca05a1c3845a779907760d9b2bf68903d4 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Mon, 24 Aug 2026 09:39:08 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20AtchaV2=20UI=20=EA=B3=A0=EB=8F=84?= =?UTF-8?q?=ED=99=94=20=E2=80=94=20=ED=86=A0=EC=8A=A4=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=ED=99=88=C2=B7=EA=B2=80=EC=83=89=20(=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EB=8B=A8=20=EC=A0=84=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 홈: 브랜드 워드마크 강등 + 큰 컨텍스트 타이틀("어디로 가세요?") + 출발/도착 그룹 필드 카드(DSGroupedCard + HomeFieldRow) + "최근 경로" 섹션 헤더 + 하단 고정 알람 CTA(스크롤 밖, safeArea 위) + 통과형 토스트 호스트(CTA 위 앵커) - 검색: 필드 2개를 홈과 같은 그룹 카드로 수납(전폭 구분선 제거), aboveKeyboardArea 앵커만 이동(키보드 위 빈 상태 중앙 배치 유지) - 모션(핵심만): DSPressEffect 프레스 스케일(버튼·칩·행·경로 카드 셀) + 첫 진입 페이드인(검색 왕복 시 무재생, reduce motion 존중) - DS 신규 2종(DSGroupedCard·DSPressEffect)은 양 화면 공용 — 테스트+갤러리 데모 포함, 기존 컴포넌트·토큰은 무수정 - ViewModel/UseCase/Coordinator 계약 불변 — render()는 기계적 치환 3곳뿐 - 자동 검수: XCUITest 하니스 8케이스 통과, 증적 docs/verification/ui-redesign-toss Co-Authored-By: Claude Fable 5 --- .../DesignSystem/Example/ComponentDemos.swift | 18 ++ .../Sources/Components/DSGroupedCard.swift | 58 +++++ .../Sources/Components/DSPressEffect.swift | 34 +++ .../Tests/DSGroupedCardTests.swift | 48 +++++ .../Tests/DSPressEffectTests.swift | 34 +++ .../Feature/Home/Sources/HomeFieldRow.swift | 87 ++++++++ .../Home/Sources/HomeViewController.swift | 198 ++++++++++++------ .../Home/Sources/PassthroughView.swift | 10 + .../Home/Tests/HomeFieldRowTests.swift | 60 ++++++ .../Search/Sources/SearchViewController.swift | 59 ++++-- .../ui-redesign-toss/t01-home-initial.png | Bin 0 -> 10844 bytes .../ui-redesign-toss/t03-search-entry.png | Bin 0 -> 9167 bytes .../t04-search-empty-above-keyboard.png | Bin 0 -> 19276 bytes .../ui-redesign-toss/t05a-search-routes.png | Bin 0 -> 10217 bytes .../t05b-home-routecard-cta.png | Bin 0 -> 15639 bytes .../t06a-home-banner-cancel-cta.png | Bin 0 -> 16941 bytes .../ui-redesign-toss/t08b-toast-above-cta.png | Bin 0 -> 18137 bytes 17 files changed, 529 insertions(+), 77 deletions(-) create mode 100644 Projects/DesignSystem/Sources/Components/DSGroupedCard.swift create mode 100644 Projects/DesignSystem/Sources/Components/DSPressEffect.swift create mode 100644 Projects/DesignSystem/Tests/DSGroupedCardTests.swift create mode 100644 Projects/DesignSystem/Tests/DSPressEffectTests.swift create mode 100644 Projects/Feature/Home/Sources/HomeFieldRow.swift create mode 100644 Projects/Feature/Home/Sources/PassthroughView.swift create mode 100644 Projects/Feature/Home/Tests/HomeFieldRowTests.swift create mode 100644 docs/verification/ui-redesign-toss/t01-home-initial.png create mode 100644 docs/verification/ui-redesign-toss/t03-search-entry.png create mode 100644 docs/verification/ui-redesign-toss/t04-search-empty-above-keyboard.png create mode 100644 docs/verification/ui-redesign-toss/t05a-search-routes.png create mode 100644 docs/verification/ui-redesign-toss/t05b-home-routecard-cta.png create mode 100644 docs/verification/ui-redesign-toss/t06a-home-banner-cancel-cta.png create mode 100644 docs/verification/ui-redesign-toss/t08b-toast-above-cta.png diff --git a/Projects/DesignSystem/Example/ComponentDemos.swift b/Projects/DesignSystem/Example/ComponentDemos.swift index ffc912a5..de3afb89 100644 --- a/Projects/DesignSystem/Example/ComponentDemos.swift +++ b/Projects/DesignSystem/Example/ComponentDemos.swift @@ -30,6 +30,15 @@ final class ButtonsDemoViewController: GalleryScreenViewController { disabledChip.setText("→ 구로디지털단지역") disabledChip.isEnabled = false [chip, disabledChip].forEach { contentStack.addArrangedSubview(leadingRow($0)) } + + addSectionTitle("DSPressEffect") + let pressButton = DSButton(title: "눌러 보세요 (스케일 다운)", style: .primary) + DSPressEffect.apply(to: pressButton) + contentStack.addArrangedSubview(pressButton) + let pressChip = DSChip() + pressChip.setText("→ 칩도 눌러 보세요") + DSPressEffect.apply(to: pressChip) + contentStack.addArrangedSubview(leadingRow(pressChip)) } /// 칩은 자기 크기 컴포넌트 — 스택 전폭으로 늘리지 않고 leading에 붙인다(홈과 같은 배치). @@ -55,6 +64,15 @@ final class FieldsDemoViewController: GalleryScreenViewController { contentStack.addArrangedSubview(titleBar) let searchBar = DSNavigationBar(style: .search(placeholder: "장소 검색")) contentStack.addArrangedSubview(searchBar) + + addSectionTitle("DSGroupedCard") + let grouped = DSGroupedCard( + rows: [ + DSTextField(placeholder: "출발지 입력"), + DSTextField(placeholder: "도착지 입력", showsAccentDot: true), + ] + ) + contentStack.addArrangedSubview(grouped) } } diff --git a/Projects/DesignSystem/Sources/Components/DSGroupedCard.swift b/Projects/DesignSystem/Sources/Components/DSGroupedCard.swift new file mode 100644 index 00000000..0668f196 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSGroupedCard.swift @@ -0,0 +1,58 @@ +import UIKit + +/// 여러 행을 한 표면으로 묶는 그룹 카드 — 홈·검색의 출발/도착 필드처럼 연관 행을 +/// 하나의 카드로 보여줄 때 쓴다. 행 사이엔 leading 인셋 헤어라인이 들어간다. +/// 행 구성은 init 고정 — 현재 호출부(홈·검색 모두 행 2개 정적)에 변경 API가 불필요하다. +public final class DSGroupedCard: UIView { + private static let separatorThickness: CGFloat = 0.5 + + public init(rows: [UIView]) { + super.init(frame: .zero) + backgroundColor = DSColor.Fill.surface + layer.cornerRadius = DSRadius.lg + // 행의 하이라이트 fill이 모서리 밖으로 새지 않게 자른다. + clipsToBounds = true + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 0 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + for (index, row) in rows.enumerated() { + if index > 0 { + stack.addArrangedSubview(Self.makeSeparatorRow()) + } + stack.addArrangedSubview(row) + } + } + + /// DSSeparator는 전폭 고정이라 재사용하지 않는다 — 카드 내부 구분선은 + /// leading만 콘텐츠 인셋을 따르는 별도 헤어라인이다. + private static func makeSeparatorRow() -> UIView { + let container = UIView() + let line = UIView() + line.backgroundColor = DSColor.Border.default + line.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(line) + NSLayoutConstraint.activate([ + line.topAnchor.constraint(equalTo: container.topAnchor), + line.bottomAnchor.constraint(equalTo: container.bottomAnchor), + line.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: DSSpacing.md), + line.trailingAnchor.constraint(equalTo: container.trailingAnchor), + line.heightAnchor.constraint(equalToConstant: Self.separatorThickness), + ]) + return container + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSPressEffect.swift b/Projects/DesignSystem/Sources/Components/DSPressEffect.swift new file mode 100644 index 00000000..586aa753 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSPressEffect.swift @@ -0,0 +1,34 @@ +import UIKit + +/// 프레스 스케일 다운 효과 — 누르는 동안 살짝 줄었다가 떼면 복원된다. +/// 호출부 옵트인(addAction 부착)이라 기존 컴포넌트 코드는 건드리지 않는다. +public enum DSPressEffect { + static let pressedScale: CGFloat = 0.97 + private static let duration: TimeInterval = 0.15 + + /// UIControl 계열(UIButton·DSChip·커스텀 행)에 터치 이벤트로 부착한다. + public static func apply(to control: UIControl) { + control.addAction( + UIAction { [weak control] _ in control.map { setPressed(true, on: $0) } }, + for: [.touchDown, .touchDragEnter] + ) + control.addAction( + UIAction { [weak control] _ in control.map { setPressed(false, on: $0) } }, + for: [.touchUpInside, .touchUpOutside, .touchCancel, .touchDragExit] + ) + } + + /// UIControl이 아닌 표면(셀의 setHighlighted 등)에서 직접 호출하는 심. + public static func setPressed(_ pressed: Bool, on view: UIView) { + guard !UIAccessibility.isReduceMotionEnabled else { return } + UIView.animate( + withDuration: duration, + delay: 0, + options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseOut] + ) { + view.transform = pressed + ? CGAffineTransform(scaleX: pressedScale, y: pressedScale) + : .identity + } + } +} diff --git a/Projects/DesignSystem/Tests/DSGroupedCardTests.swift b/Projects/DesignSystem/Tests/DSGroupedCardTests.swift new file mode 100644 index 00000000..33362da8 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSGroupedCardTests.swift @@ -0,0 +1,48 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSGroupedCardTests { + @Test + func cardSurfaceAndRadius() { + let card = DSGroupedCard(rows: [UIView(), UIView()]) + #expect(card.backgroundColor.map { colorsMatch($0, DSColor.Fill.surface) } == true) + #expect(card.layer.cornerRadius == DSRadius.lg) + #expect(card.clipsToBounds) + } + + @Test + func rowsAreInterleavedWithSeparators() throws { + let rows = [UIView(), UIView(), UIView()] + let card = DSGroupedCard(rows: rows) + let stack = try #require(card.subviews.first as? UIStackView) + // 행 3개 + 사이 구분선 2개 = 5, 순서는 행-선-행-선-행. + #expect(stack.arrangedSubviews.count == 5) + #expect(stack.arrangedSubviews[0] === rows[0]) + #expect(stack.arrangedSubviews[2] === rows[1]) + #expect(stack.arrangedSubviews[4] === rows[2]) + } + + @Test + func separatorIsInsetHairline() throws { + let card = DSGroupedCard(rows: [UIView(), UIView()]) + let stack = try #require(card.subviews.first as? UIStackView) + let separatorContainer = stack.arrangedSubviews[1] + let line = try #require(separatorContainer.subviews.first) + #expect(line.backgroundColor.map { colorsMatch($0, DSColor.Border.default) } == true) + let heightConstraint = line.constraints.first { $0.firstAttribute == .height } + #expect(heightConstraint?.constant == 0.5) + let leadingConstraint = separatorContainer.constraints.first { + $0.firstAttribute == .leading || $0.secondAttribute == .leading + } + #expect(leadingConstraint?.constant == DSSpacing.md) + } + + @Test + func singleRowHasNoSeparator() throws { + let card = DSGroupedCard(rows: [UIView()]) + let stack = try #require(card.subviews.first as? UIStackView) + #expect(stack.arrangedSubviews.count == 1) + } +} diff --git a/Projects/DesignSystem/Tests/DSPressEffectTests.swift b/Projects/DesignSystem/Tests/DSPressEffectTests.swift new file mode 100644 index 00000000..d5bc8c02 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSPressEffectTests.swift @@ -0,0 +1,34 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSPressEffectTests { + // UIView.animate는 호스트리스에서도 모델 값을 즉시 반영한다 — 기존 테스트 규약과 동일. + @Test + func pressedAppliesScaleDown() { + let view = UIView() + DSPressEffect.setPressed(true, on: view) + #expect(abs(view.transform.a - DSPressEffect.pressedScale) < 0.0001) + #expect(abs(view.transform.d - DSPressEffect.pressedScale) < 0.0001) + } + + @Test + func releaseRestoresIdentity() { + let view = UIView() + DSPressEffect.setPressed(true, on: view) + DSPressEffect.setPressed(false, on: view) + #expect(view.transform == .identity) + } + + // 이벤트 발화는 UIApplication이 필요하다 — 부착이 깨지지 않는 것까지만 검증하고 + // 눌림 동작 자체는 setPressed 심으로 검증한다(위 두 테스트). + @Test + func applyAttachesWithoutSideEffects() { + let control = UIControl() + DSPressEffect.apply(to: control) + #expect(control.transform == .identity) + #expect(control.allControlEvents.contains(.touchDown)) + #expect(control.allControlEvents.contains(.touchUpInside)) + } +} diff --git a/Projects/Feature/Home/Sources/HomeFieldRow.swift b/Projects/Feature/Home/Sources/HomeFieldRow.swift new file mode 100644 index 00000000..8750e1e1 --- /dev/null +++ b/Projects/Feature/Home/Sources/HomeFieldRow.swift @@ -0,0 +1,87 @@ +import DesignSystem +import SnapKit +import UIKit + +/// 홈의 출발/도착 행 — 편집이 아니라 검색 진입 트리거다. DSGroupedCard 안에 +/// DSTextField를 넣으면 surface 위 surface 중첩이 되므로 카드 행에 맞는 평면 +/// 행으로 대체한다. 텍스트 규약은 DSTextField와 동일: 빈 값이면 placeholder가 +/// 유도 문구 역할을 한다. 우측 셰브런이 "탭하면 검색으로 이동"을 시사한다. +final class HomeFieldRow: UIControl { + private static let height: CGFloat = 56 + + private let placeholder: String + private let textLabel = UILabel() + + init(icon: UIImage, placeholder: String, showsAccentDot: Bool = false) { + self.placeholder = placeholder + super.init(frame: .zero) + + let iconView = UIImageView(image: icon) + iconView.tintColor = DSColor.Icon.default + iconView.contentMode = .scaleAspectFit + + let chevronView = UIImageView(image: DSIcon.chevronRight16) + chevronView.tintColor = DSColor.Icon.muted + chevronView.contentMode = .scaleAspectFit + + textLabel.font = DSTypography.body1.font + + [iconView, textLabel, chevronView].forEach(addSubview) + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(DSSpacing.md) + make.centerY.equalToSuperview() + make.size.equalTo(DSIconSize.lg) + } + + let textLeading: ConstraintItem + if showsAccentDot { + let dot = UIView() + dot.backgroundColor = DSColor.Accent.default + dot.layer.cornerRadius = 2 + addSubview(dot) + dot.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(DSSpacing.sm) + make.centerY.equalToSuperview() + make.size.equalTo(4) + } + textLeading = dot.snp.trailing + } else { + textLeading = iconView.snp.trailing + } + textLabel.snp.makeConstraints { make in + make.leading.equalTo(textLeading).offset(DSSpacing.sm) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-DSSpacing.sm) + } + chevronView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(DSSpacing.md) + make.centerY.equalToSuperview() + make.size.equalTo(DSIconSize.sm) + } + snp.makeConstraints { make in + make.height.equalTo(Self.height) + } + + isAccessibilityElement = true + accessibilityTraits = .button + accessibilityLabel = placeholder + setText("") + } + + func setText(_ text: String) { + if text.isEmpty { + textLabel.text = placeholder + textLabel.textColor = DSColor.Text.secondary + accessibilityValue = nil + } else { + textLabel.text = text + textLabel.textColor = DSColor.Text.primary + accessibilityValue = text + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index 4266d3c8..e6694807 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -8,23 +8,33 @@ final class HomeViewController: UIViewController { // VC strongly owns the VM; the VM's closures capture the VC weakly. private let viewModel: HomeViewModel - private let titleLabel: UILabel = { + // 토스식 위계: 브랜드는 작은 워드마크로 물러나고 시각적 무게는 컨텍스트 + // 타이틀("어디로 가세요?")이 가져간다. 타이틀은 정적 카피 — 상태 무관. + private let brandLabel: UILabel = { let label = UILabel() - label.font = DSTypography.title1.font + label.font = DSTypography.heading.font label.textColor = DSColor.Accent.default label.text = "앗차" return label }() + private let bigTitleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.attributedText = DSTypography.title1.attributed( + "어디로 가세요?", color: DSColor.Text.primary + ) + return label + }() + private let banner = DSBanner() - private let departureField = DSTextField(placeholder: "출발지를 검색해 주세요", showsAccentDot: true) - private let arrivalField = DSTextField(placeholder: "도착지를 검색해 주세요") - private lazy var departureRow = makeFieldRow( - icon: DSIcon.myLocation24, field: departureField, entry: .departure + private let departureRow = HomeFieldRow( + icon: DSIcon.myLocation24, placeholder: "출발지를 검색해 주세요", showsAccentDot: true ) - private lazy var arrivalRow = makeFieldRow( - icon: DSIcon.place24, field: arrivalField, entry: .arrival + private let arrivalRow = HomeFieldRow( + icon: DSIcon.place24, placeholder: "도착지를 검색해 주세요" ) + private lazy var fieldCard = DSGroupedCard(rows: [departureRow, arrivalRow]) // 최근 경로 원탭 칩(Phase 18) — 자기 크기 컴포넌트라 스택 전폭으로 늘리지 않고 // 래퍼의 leading에 붙인다. 표시·활성은 render가 State로 반영한다. private let recentRouteChip = DSChip() @@ -37,10 +47,29 @@ final class HomeViewController: UIViewController { } return row }() + // 칩 위에 섹션 헤더를 얹는다 — 표시·숨김은 헤더와 칩이 한 몸으로 움직인다. + private lazy var recentSection: UIStackView = { + let header = UILabel() + header.font = DSTypography.label2.font + header.textColor = DSColor.Text.secondary + header.text = "최근 경로" + let stack = UIStackView(arrangedSubviews: [header, chipRow]) + stack.axis = .vertical + stack.spacing = DSSpacing.sm + return stack + }() private let routeCard = DSRouteCard() private let registerButton = DSButton(title: "알람 등록하기") // DSButton은 title이 init 고정이라 토글은 버튼 2개의 표시 전환으로 구현한다. private let cancelButton = DSButton(title: "알람 해제하기", style: .secondary) + // 하단 고정 CTA — 스택이라 두 버튼 모두 숨김이면 높이 0으로 접힌다. + private let ctaStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + return stack + }() + // 토스트가 하단 CTA를 덮지 않도록 CTA 위에 얹는 통과형 호스트. + private let toastHost = PassthroughView() private let contentStack: UIStackView = { let stack = UIStackView() @@ -57,6 +86,9 @@ final class HomeViewController: UIViewController { }() private let refreshControl = UIRefreshControl() + // 진입 페이드는 최초 1회만 — 검색 왕복마다 재생하면 홈이 깜빡인다. + private var hasPlayedEntry = false + init(viewModel: HomeViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -93,6 +125,49 @@ final class HomeViewController: UIViewController { navigationController?.setNavigationBarHidden(true, animated: animated) // 검색 화면을 다녀오며 바뀐 최근 검색(삭제 포함)을 칩에 반영한다(Phase 18). viewModel.viewWillAppear() + prepareEntryTransitionIfNeeded() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + playEntryTransitionIfNeeded() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + // 하단 고정 CTA에 콘텐츠 끝이 가리지 않게 바닥 인셋을 맞춘다(숨김이면 0). + // 동일 값 재대입 가드 — viewDidLayoutSubviews 재귀 레이아웃 루프 방지. + let ctaHeight = ctaStack.bounds.height + let bottomInset = ctaHeight > 0 ? ctaHeight + DSSpacing.md : 0 + if scrollView.contentInset.bottom != bottomInset { + scrollView.contentInset.bottom = bottomInset + scrollView.verticalScrollIndicatorInsets.bottom = bottomInset + } + } + + // MARK: - 진입 페이드 (최초 1회) + + private func prepareEntryTransitionIfNeeded() { + guard !hasPlayedEntry else { return } + let translation = UIAccessibility.isReduceMotionEnabled + ? CGAffineTransform.identity + : CGAffineTransform(translationX: 0, y: 8) + [contentStack, ctaStack].forEach { + $0.alpha = 0 + $0.transform = translation + } + } + + private func playEntryTransitionIfNeeded() { + guard !hasPlayedEntry else { return } + hasPlayedEntry = true + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { [weak self] in + guard let self else { return } + [contentStack, ctaStack].forEach { + $0.alpha = 1 + $0.transform = .identity + } + } } // MARK: - UI @@ -120,17 +195,31 @@ final class HomeViewController: UIViewController { make.width.equalTo(scrollView.frameLayoutGuide).offset(-DSSpacing.md * 2) } - [titleLabel, banner, departureRow, arrivalRow, chipRow, routeCard, registerButton, cancelButton] + [brandLabel, banner, bigTitleLabel, fieldCard, recentSection, routeCard] .forEach(contentStack.addArrangedSubview) contentStack.addArrangedSubview(makeCaptionStack()) - contentStack.setCustomSpacing(DSSpacing.lg20, after: titleLabel) - contentStack.setCustomSpacing(DSSpacing.sm, after: departureRow) - // 칩이 숨겨져도 필드→카드 간격이 기존(lg)과 같도록 앞뒤 모두 lg를 쓴다. - contentStack.setCustomSpacing(DSSpacing.lg, after: arrivalRow) - contentStack.setCustomSpacing(DSSpacing.lg, after: chipRow) + contentStack.setCustomSpacing(DSSpacing.lg20, after: bigTitleLabel) + // 칩 섹션이 숨겨져도 카드→경로 카드 간격이 같도록 앞뒤 모두 lg를 쓴다. + contentStack.setCustomSpacing(DSSpacing.lg, after: fieldCard) + contentStack.setCustomSpacing(DSSpacing.lg, after: recentSection) + contentStack.setCustomSpacing(DSSpacing.lg, after: routeCard) + + // 알람 CTA는 스크롤 밖 하단 고정(토스식) — 표시 토글은 render가 그대로 수행한다. + [registerButton, cancelButton].forEach(ctaStack.addArrangedSubview) + view.addSubview(ctaStack) + ctaStack.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(DSSpacing.md) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(DSSpacing.sm) + } + + view.addSubview(toastHost) + toastHost.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(ctaStack.snp.top) + } banner.isHidden = true - chipRow.isHidden = true + recentSection.isHidden = true routeCard.isHidden = true registerButton.isHidden = true cancelButton.isHidden = true @@ -147,35 +236,18 @@ final class HomeViewController: UIViewController { UIAction { [weak self] _ in self?.viewModel.cancelAlarmTapped() }, for: .touchUpInside ) - } - - /// 홈의 필드는 편집이 아니라 검색 진입 트리거다. DSTextField에는 편집 시작 훅이 - /// 없으므로 필드 터치를 통째로 죽이고 UIControl 래퍼가 탭을 가져간다. - /// 탭한 필드가 검색 진입 슬롯이 된다(Phase 17) — entry가 그대로 넘어간다. - private func makeFieldRow( - icon: UIImage, field: DSTextField, entry: SearchEntryField - ) -> UIControl { - let row = UIControl() - let iconView = UIImageView(image: icon) - iconView.tintColor = DSColor.Icon.default - iconView.contentMode = .scaleAspectFit - field.isUserInteractionEnabled = false - - [iconView, field].forEach(row.addSubview) - iconView.snp.makeConstraints { make in - make.leading.equalToSuperview() - make.centerY.equalToSuperview() - make.size.equalTo(DSIconSize.lg) - } - field.snp.makeConstraints { make in - make.leading.equalTo(iconView.snp.trailing).offset(DSSpacing.sm) - make.top.trailing.bottom.equalToSuperview() - } - row.addAction( - UIAction { [weak self] _ in self?.viewModel.searchFieldTapped(entry) }, + // 행 전체 탭 → 검색 진입. 탭한 필드가 검색 진입 슬롯이 된다(Phase 17). + departureRow.addAction( + UIAction { [weak self] _ in self?.viewModel.searchFieldTapped(.departure) }, for: .touchUpInside ) - return row + arrivalRow.addAction( + UIAction { [weak self] _ in self?.viewModel.searchFieldTapped(.arrival) }, + for: .touchUpInside + ) + + [registerButton, cancelButton, recentRouteChip, departureRow, arrivalRow] + .forEach { DSPressEffect.apply(to: $0) } } private func makeCaptionStack() -> UIStackView { @@ -231,23 +303,23 @@ final class HomeViewController: UIViewController { private func render(_ state: HomeViewModel.State) { switch state.departure { case .loading: - departureField.setText("현재 위치 확인 중...") + departureRow.setText("현재 위치 확인 중...") case let .current(name): - departureField.setText(name) + departureRow.setText(name) case .needsSearch: // 빈 값이면 placeholder("출발지를 검색해 주세요")가 유도 문구 역할을 한다. - departureField.setText("") + departureRow.setText("") } // 도착지 필드 = 선택 경로의 도착지명(Phase 17). nil이면 placeholder가 유도한다. - arrivalField.setText(state.arrivalText ?? "") + arrivalRow.setText(state.arrivalText ?? "") // 최근 경로 원탭 칩(Phase 18) — nil이면 숨김, 재검색 진행 중엔 비활성(더블 탭 방지). if let chipText = state.recentRouteChipText { recentRouteChip.setText(chipText) - chipRow.isHidden = false + recentSection.isHidden = false } else { - chipRow.isHidden = true + recentSection.isHidden = true } recentRouteChip.isEnabled = !state.isChipBusy @@ -289,7 +361,7 @@ final class HomeViewController: UIViewController { case .locationPermissionNeeded: DSToast.show( "위치 권한이 꺼져 있어요", - in: view, + in: toastHost, action: .init(title: "설정으로 이동") { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) @@ -298,7 +370,7 @@ final class HomeViewController: UIViewController { case .locationServicesDisabled: DSToast.show( "기기의 위치 서비스가 꺼져 있어요", - in: view, + in: toastHost, action: .init(title: "설정으로 이동") { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) @@ -306,50 +378,50 @@ final class HomeViewController: UIViewController { ) case .locationRestricted: // restricted는 설정으로 못 푸는 제약 — "설정으로 이동"을 안내하지 않는다(Phase 17). - DSToast.show("이 기기에선 위치를 사용할 수 없어요. 출발지를 검색해 주세요", in: view) + DSToast.show("이 기기에선 위치를 사용할 수 없어요. 출발지를 검색해 주세요", in: toastHost) case .chipLocationUnavailable: - DSToast.show("현재 위치를 확인하지 못했어요. 잠시 후 다시 시도해 주세요", in: view) + DSToast.show("현재 위치를 확인하지 못했어요. 잠시 후 다시 시도해 주세요", in: toastHost) case .chipSearchFailed: - DSToast.show("막차를 찾지 못했어요. 다시 시도해 주세요", in: view) + DSToast.show("막차를 찾지 못했어요. 다시 시도해 주세요", in: toastHost) case .chipServiceEnded: // 검색 화면 빈 상태 제목과 같은 어휘(Phase 18) — 화면 간 표기 통일. - DSToast.show("오늘 막차가 끊겼어요", in: view) + DSToast.show("오늘 막차가 끊겼어요", in: toastHost) case .chipNoRoute: - DSToast.show("대중교통 경로를 찾지 못했어요", in: view) + DSToast.show("대중교통 경로를 찾지 못했어요", in: toastHost) case .alarmPermissionNeeded: DSToast.show( "알람 권한이 꺼져 있어요", - in: view, + in: toastHost, action: .init(title: "설정으로 이동") { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } ) case .alarmRegisterFailed: - DSToast.show("알람 등록에 실패했어요. 다시 시도해 주세요.", in: view) + DSToast.show("알람 등록에 실패했어요. 다시 시도해 주세요.", in: toastHost) case .alarmTooLate: - DSToast.show("이미 출발 시간이 지난 경로예요", in: view) + DSToast.show("이미 출발 시간이 지난 경로예요", in: toastHost) case .notificationPermissionDenied: DSToast.show( "막차 변경 알림을 받으려면 설정에서 알림을 허용해주세요", - in: view, + in: toastHost, action: .init(title: "설정으로 이동") { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } ) case .alarmCancelFailed: - DSToast.show("알람 해제에 실패했어요. 다시 시도해 주세요.", in: view) + DSToast.show("알람 해제에 실패했어요. 다시 시도해 주세요.", in: toastHost) case let .lastTrainAdvanced(minutes): // 배너 강조는 이 원샷 이벤트에 부수하는 시각 효과 — 별도 채널을 만들지 않는다. // 배너의 시각·긴급도 값 자체는 info 스트림이 State로 이미 갱신한다. - DSToast.show("막차가 \(minutes)분 당겨졌어요", in: view) + DSToast.show("막차가 \(minutes)분 당겨졌어요", in: toastHost) banner.emphasize() case .lastTrainMissed: // TODO: [미확정 #9] 대안 제시(심야버스 등) 데이터 소스 확정 전까지 실패 문구만 표출한다. - DSToast.show("막차가 지나갔어요", in: view) + DSToast.show("막차가 지나갔어요", in: toastHost) case .lastTrainServiceEnded: - DSToast.show("오늘 운행이 끝나 알람을 정리했어요", in: view) + DSToast.show("오늘 운행이 끝나 알람을 정리했어요", in: toastHost) } } } diff --git a/Projects/Feature/Home/Sources/PassthroughView.swift b/Projects/Feature/Home/Sources/PassthroughView.swift new file mode 100644 index 00000000..71c0ad4c --- /dev/null +++ b/Projects/Feature/Home/Sources/PassthroughView.swift @@ -0,0 +1,10 @@ +import UIKit + +/// 토스트 전용 오버레이 호스트 — 서브뷰(토스트)에 명중할 때만 터치를 소비하고 +/// 그 외 영역은 아래 계층(스크롤·하단 CTA)으로 통과시킨다. +final class PassthroughView: UIView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let view = super.hitTest(point, with: event) + return view === self ? nil : view + } +} diff --git a/Projects/Feature/Home/Tests/HomeFieldRowTests.swift b/Projects/Feature/Home/Tests/HomeFieldRowTests.swift new file mode 100644 index 00000000..8a2b397d --- /dev/null +++ b/Projects/Feature/Home/Tests/HomeFieldRowTests.swift @@ -0,0 +1,60 @@ +import DesignSystem +@testable import HomeFeature +import Testing +import UIKit + +@MainActor +struct HomeFieldRowTests { + @Test + func emptyTextShowsPlaceholderInSecondary() { + let row = HomeFieldRow(icon: UIImage(), placeholder: "출발지를 검색해 주세요") + let label = row.subviews.compactMap { $0 as? UILabel }.first + #expect(label?.text == "출발지를 검색해 주세요") + #expect(label.map { colorsMatch($0.textColor, DSColor.Text.secondary) } == true) + #expect(row.accessibilityValue == nil) + } + + @Test + func valueShowsInPrimaryAndClearsBackToPlaceholder() { + let row = HomeFieldRow(icon: UIImage(), placeholder: "출발지를 검색해 주세요") + row.setText("강남역") + let label = row.subviews.compactMap { $0 as? UILabel }.first + #expect(label?.text == "강남역") + #expect(label.map { colorsMatch($0.textColor, DSColor.Text.primary) } == true) + #expect(row.accessibilityValue == "강남역") + + row.setText("") + #expect(label?.text == "출발지를 검색해 주세요") + #expect(label.map { colorsMatch($0.textColor, DSColor.Text.secondary) } == true) + } + + @Test + func rowHeightMatchesCardRowScale() { + let row = HomeFieldRow(icon: UIImage(), placeholder: "출발지") + let height = row.constraints.first { $0.firstAttribute == .height } + #expect(height?.constant == 56) + } + + @Test + func rowIsAccessibleButton() { + let row = HomeFieldRow(icon: UIImage(), placeholder: "도착지를 검색해 주세요") + #expect(row.isAccessibilityElement) + #expect(row.accessibilityTraits.contains(.button)) + #expect(row.accessibilityLabel == "도착지를 검색해 주세요") + } +} + +/// 토큰 색은 애셋 기반이라 인스턴스 비교가 불안정하다 — 다크 트레잇으로 해석해 +/// RGBA를 비교한다(DesignSystem 테스트의 colorsMatch와 같은 방식). +@MainActor +private func colorsMatch(_ lhs: UIColor, _ rhs: UIColor) -> Bool { + let trait = UITraitCollection(userInterfaceStyle: .dark) + var l: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) = (0, 0, 0, 0) + var r: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) = (0, 0, 0, 0) + lhs.resolvedColor(with: trait).getRed(&l.red, green: &l.green, blue: &l.blue, alpha: &l.alpha) + rhs.resolvedColor(with: trait).getRed(&r.red, green: &r.green, blue: &r.blue, alpha: &r.alpha) + return abs(l.red - r.red) < 0.001 + && abs(l.green - r.green) < 0.001 + && abs(l.blue - r.blue) < 0.001 + && abs(l.alpha - r.alpha) < 0.001 +} diff --git a/Projects/Feature/Search/Sources/SearchViewController.swift b/Projects/Feature/Search/Sources/SearchViewController.swift index 9e218ad1..8f7468a1 100644 --- a/Projects/Feature/Search/Sources/SearchViewController.swift +++ b/Projects/Feature/Search/Sources/SearchViewController.swift @@ -16,8 +16,9 @@ final class SearchViewController: UIViewController { private let navigationBar = DSNavigationBar(style: .backOnly) private let departureField = DSTextField(placeholder: "출발지 입력") private let arrivalField = DSTextField(placeholder: "도착지 입력", showsAccentDot: true) - private let fieldsStack = UIStackView() - private let separator = DSSeparator(axis: .horizontal) + // 홈의 필드 카드와 같은 그룹 카드 — DSTextField bg가 카드와 같은 surface라 + // 시각적으로 융합되고, 포커스 라임 보더가 카드 안에서 그대로 동작한다. + private lazy var fieldCard = DSGroupedCard(rows: [departureField, arrivalField]) private let tableView = UITableView(frame: .zero, style: .plain) private let emptyState = DSEmptyState(content: .init(title: "")) private let activityIndicator = UIActivityIndicatorView(style: .medium) @@ -26,6 +27,9 @@ final class SearchViewController: UIViewController { private var showsRecentHeader = false private var focusedField: SearchViewModel.Field? + // 진입 페이드는 최초 1회만 — 홈과 같은 규약. + private var hasPlayedEntry = false + init(viewModel: SearchViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -47,6 +51,7 @@ final class SearchViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) + prepareEntryTransitionIfNeeded() } // 숨긴 내비바는 스와이프 백 제스처를 죽인다 — delegate를 잡아 되살린다(Phase 17). @@ -54,6 +59,32 @@ final class SearchViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) navigationController?.interactivePopGestureRecognizer?.delegate = self + playEntryTransitionIfNeeded() + } + + // MARK: - 진입 페이드 (최초 1회) + + private func prepareEntryTransitionIfNeeded() { + guard !hasPlayedEntry else { return } + let translation = UIAccessibility.isReduceMotionEnabled + ? CGAffineTransform.identity + : CGAffineTransform(translationX: 0, y: 8) + [fieldCard, tableView].forEach { + $0.alpha = 0 + $0.transform = translation + } + } + + private func playEntryTransitionIfNeeded() { + guard !hasPlayedEntry else { return } + hasPlayedEntry = true + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { [weak self] in + guard let self else { return } + [fieldCard, tableView].forEach { + $0.alpha = 1 + $0.transform = .identity + } + } } override func viewWillDisappear(_ animated: Bool) { @@ -64,10 +95,6 @@ final class SearchViewController: UIViewController { private func configureUI() { view.backgroundColor = DSColor.Background.base - fieldsStack.axis = .vertical - fieldsStack.spacing = DSSpacing.sm - [departureField, arrivalField].forEach(fieldsStack.addArrangedSubview) - tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.keyboardDismissMode = .onDrag @@ -83,23 +110,19 @@ final class SearchViewController: UIViewController { emptyState.isHidden = true - [navigationBar, fieldsStack, separator, tableView, emptyState, activityIndicator] + [navigationBar, fieldCard, tableView, emptyState, activityIndicator] .forEach(view.addSubview) navigationBar.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() } - fieldsStack.snp.makeConstraints { make in + fieldCard.snp.makeConstraints { make in make.top.equalTo(navigationBar.snp.bottom).offset(DSSpacing.xs) make.leading.trailing.equalToSuperview().inset(DSSpacing.md) } - separator.snp.makeConstraints { make in - make.top.equalTo(fieldsStack.snp.bottom).offset(DSSpacing.sm12) - make.leading.trailing.equalToSuperview() - } tableView.snp.makeConstraints { make in - make.top.equalTo(separator.snp.bottom) + make.top.equalTo(fieldCard.snp.bottom).offset(DSSpacing.sm12) make.leading.trailing.bottom.equalToSuperview() } // 빈 상태·스피너는 키보드 위 가시 영역의 중앙에 둔다(Phase 17) — 타이핑 중 뜨는 @@ -108,7 +131,9 @@ final class SearchViewController: UIViewController { let aboveKeyboardArea = UILayoutGuide() view.addLayoutGuide(aboveKeyboardArea) NSLayoutConstraint.activate([ - aboveKeyboardArea.topAnchor.constraint(equalTo: separator.bottomAnchor), + aboveKeyboardArea.topAnchor.constraint( + equalTo: fieldCard.bottomAnchor, constant: DSSpacing.sm12 + ), aboveKeyboardArea.leadingAnchor.constraint(equalTo: view.leadingAnchor), aboveKeyboardArea.trailingAnchor.constraint(equalTo: view.trailingAnchor), aboveKeyboardArea.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), @@ -403,6 +428,12 @@ private final class SearchRouteCardCell: UITableViewCell { } } + // selectionStyle .none이라도 하이라이트 콜백은 온다 — 카드에 프레스 스케일만 준다. + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + DSPressEffect.setPressed(highlighted, on: card) + } + func configure(with content: DSRouteCard.Content) { card.configure(with: content) } diff --git a/docs/verification/ui-redesign-toss/t01-home-initial.png b/docs/verification/ui-redesign-toss/t01-home-initial.png new file mode 100644 index 0000000000000000000000000000000000000000..d3ccd7eb40c6ff066fdb4d13d58a44e92e75f72a GIT binary patch literal 10844 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 jE5fTA;xR6s@2^i$e>=3KZA2xI=*!iWVsphZY{a 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 Ns3~bHzLK|%`VUNSTlfF~ literal 0 HcmV?d00001 diff --git a/docs/verification/ui-redesign-toss/t04-search-empty-above-keyboard.png b/docs/verification/ui-redesign-toss/t04-search-empty-above-keyboard.png new file mode 100644 index 0000000000000000000000000000000000000000..da5773bcbb0993ce8fe79aefb11ef0e968df9b30 GIT binary patch literal 19276 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 YfBTMmxId6;sQ>@~07*qoM6N<$g2C$a{Qv*} literal 0 HcmV?d00001 diff --git a/docs/verification/ui-redesign-toss/t05a-search-routes.png b/docs/verification/ui-redesign-toss/t05a-search-routes.png new file mode 100644 index 0000000000000000000000000000000000000000..a614b1d4e143f76d0fb6aa646abfd4c673ec815d GIT binary patch literal 10217 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

2#@lyKwWv)v5?OZ z17-|w)O%k$K8KN4RDTqlkEte9$lhUvf ztXKe{!^?mnBVn$*A}ZIY)?;2=sVs^80{(`6xeRl#a2#2AdR^W2LA@pOJzWk}w93k- zdrMc$((qsGl$<@k=~jSI=(Wfx7L)KFqqb9e51fdo9*xcTz6LUGballatBK!91CGtE z+x)w`pljq;ULh?zuD2~!X10XWtP&czO=h2qM;n@|DR>DcBl-jUoM{$yKH(f#)}s9& zMIGpKj$irBXOpETSb|$2S?z@EBircUPo{65yW=Mlx}(M@gIk5qb%(NsyUpiaUqS4E z_f>yIRswgzP(*l48FzxyVnsOzWsg0Ja<>p=kEG@ogBs9{cs$ls6X=Ga>xusgz9rha zznKYMD7>r{l>ly~uXb~p0FF|6?zt@JuZ;y4$0%ql?V%&Qtc2(3kpRD}M1Qf`z3+EE+;h(T>vli8 ztJW&5+N=6mwO6ejE-x#F1dj&~0059A#Dx_;p&tMMh6M}$iIHH!x_kmiQvn$P0H8V+ z;Z+~%vkf#9SC9b!JSaYSg8+c%&n~}10Kl0M065kI0Ju^B0BpOAmLEKygb-sj2@@F^ z0QDyh3xEJa2K=KK;qzh!LkB>7La@&-Ak-WH`7ay*;QNFCMMDQ;V;e^^Tc^(k|D{R52*DlzfWS3ZQgc$1k>)bA zwWilMvNbTKceA$pM+Cs*#`TF>8$0O(-K?!_9J$Pqj*LT~F}%E0vf z`*#LLW(H_)3g@K8lk>UR&=45X2e-ZnK@_!XGGW<`k?3^8}{^gUA zA%n4%v9+;{ljElx6T^SH@;OqTe+I!NZ|-Jnr7mp#$>aFx5+5@=Gc(V>#`nLG{>#IE ziz+)BI|$iY|D&DnzvlBlg#SD7|1J10n`-}MlZly;{lDA%Z>0Yw`j7Kmat`L7=Jfx$ z`+Q714F6l-f8#eYO+3QDfinZj zT>!dv*`SeIvOthew`$qSr1t0No-{?le(N2fJbkhiJNFk_%!ntRFOc>>#6*~AfWMo` zVUlb@Q7%FNMhnx^9fo4Qv{>z4Pw-F~qeVAE@D(tVx%W#PRM zU?(=Xkd?J=$N5XTFf=qYBqYSKxqRH@=KZ#-QnRoX*@zReJP3VbWT zH0w#y^93t;^zHe?){n;j<^JsA{M_Rp^C5=F+sMR(0u#RD^?H&J`Q=&EyZW|fA;#9{ zCYzY^5;&gaAtNo_b~_{0+nYz9e9?UHemG2-?Z8P8d6wg~HXs!vmY%;(#eB# zo&u;5A0oJ^&@}YCKV5F9I2lW2nVp@z1iv-2w#FrBd%7AV=D!)6W$1hzF?d>>)%_yu zgT`3{1<|p|m{20z-lp@AL}%^j=vXA1`J1rI6FKzma9S*unBSz4!G3k2LeuxYHrdqF z)Wr0lG&q4Mq7~?;K>dY5i}T}gC-%T0_~>XkDTxmCsrex5Wi#+0Z@Mp@EY!oJAvPxF zs8$1I5`cschO5AuJW8e`DSqvM8~<~BUoMl&ygDV@ZF^WMnf~qEwKG=KaIfa)BsPOi zTbQ&9S7su$PKTGfhet#u^_%cs;`u&3tR8iuWVo<{51+@`%KU7RoJTt!YIb^h{c5J6 zVKOCUV@PlJqK@ZZi}v(B=RM17vM8HswM`#*U0N(UtaR|sL`hoQlyPI6^W9;^ii(QQ zkhEdN`yybB@$KVrkl+1gys_>MDtc=xUYcUi&FG2xn`Z>}+72Lh0C z%_k570ps1x`t;CXc!IMT*rRBE=5;CiR0edC| zc%9>mTN4A<6>kCw2FO--zak3+TlC@t5Ek1vR55atkRP6rc^5ZYST>_MTTvP*OlD$z zeLI&8j+cmsiy|Bwf71M+|Ld8`t_+Wdro>)-cyP^q-3+egshRPo3Iol^UM(WH#(=5u z6t=vorm-YWvtYzRCF%=S$gk>2lT076mn=OeS~gx(i050YmHnz3!E~v-HT9{0KMLof z%q{(#{YqnBOhXM!($PHeVjIECXYs;9P~7l^SlYHCBW(t#+-)qa7B9!+zn z>aS9_OH{v3?6;K&0m36K5Xnx7)MyP}Ap#O3=V8s;l~z?pw-O+K3Ri6?d{lQRg)>n3 z>t_ZKrg})cz?+rJUOh_62#?2MCe5tHVa~(`048?q8yj8oS&phTce?d1M|Iz4$|~Q% zne`F}HOI1Wi}B_0@yxQ7CY{jFk%Puu99`0ZE-R`jyhi&Z9AUsT{~YY8=YOprRC(LWit7k z_hB|ZI4I>ZU_&)K9(r7Z=Tj71Jb6+|KK69qMQD@kILA_?i1@r>|kIs_}tf5`kq~GgmG6IYi*x&-Q`&*##Y$b@=Oc!g z=-5F+92{FUw9oMv$yrJk{OmW-fB3`u_wOn{pXYf+<2p4~|2J4Hn`mLZvG+{w*LS~` zCy1vgI15r-n5nBP2^S-^iqix%Z(5M=+sVdKr|ShxkGS@*aVSjs>S}`yz51oX zLJFVf*=!;%r$Z?@{?0R^vHIM52rdHS*-O?nTeR#&&nFX-mxSv$p5p)~?g=J^m;hqiEghhQki9 zt6M^Pa@U>(IC8r0d_DNMom14s!6oFi--c+=gAG@pemmQEr%OjZB;USDncb^dDUTP2 zm2wyU{y>)3$QU=6L-cLADG*ht3iQ);!+p`F(H}`GHmWDY)5s{y+enW0ZnyE{F;*%# zVDN+@evrsF>RT+Yi+II+ePi~^9)qqBw8t8~^g0XfEh_$fDJh^{*h5-cTK^Jx zR-JZ1iTdrx6x;uFt=aJuC7$sc3QyI^O#dhgi{nM-2Vc4~d_VJF=9eU#mfoYBY}q2; z*BxJgVt5DmIK9uaIt7_%M!p2Mp(0XyfI5?@qxw%meEji)(^_V1_GX6_$D;)S>zeG`acOhVnnTrco!mtYX9+DM(egu0Qat2)TWT=?| zzrNB8a~s_ZZ*$%u}?=Ez6Ml>JKq0Z1m0!EW)JoCpgj3*`JMWg78txnJSRLaUj(U zAs0Ab_MQRX-^<8a7n_Do7(7KukPx)90Z!>r_J@|a{sJK(n-#BuAckq|i0BrlVj;j( zBXev0${y#e85m7(n-`)T%$p!d=zCrgv@fFzcSYl^VWUxn`M`XWjQs9_Nh`TQ2V z`lCnpNbwD7k_H)Uo0UorYdbHp=I_#}bBu9=i-^6v<>Mve%V0spUFB&2NSoF5cH7Ag zJx+Cqw4iNW2gCS(H6sg2Hr`a@n%m)gg*#YQq5_}jaC=s^GY_#a+oXTF!+ zN#a2w^4XJvjh~sQ&=GNXUDaW-bfJcZ=2QR_WBesd)AD+2=6uJ*LW;IAiq&!4H7DiAU=VQ|h zR#yijL9yvp5Fu7ipc>+0QhI#z2}KHcE7cnycGV^n&TK`ZHgRB4nsN=@-r*A_7a#`Eb zCbU|d?0tN62x&>&(OGa?WZnaLd$(Ypotm8axLn_!H#ax$hKH#ju_w+%#X_$fLS}FV z24|vEx4Hmm&|8>knCp{zUIdu}A&v)p;r&bLAw6PS)8|!1KdJWP@*%XR$KwV6=B{Yy zYo{}K`wgCDWo6}~k@c~m9i!$*l2M5GCzw~EGNvKq?mvVg6LGm6&rzj;6O_8EH-9;& zB>|`hU<0oO>A$7o9!yb=db+8a9Xx#%mI*zAE3H;&7B<~+X(d6taPPPC1A|~q@Mv}* zNADJg11C?gO4Jr`P#LLH;Q*>vrfyGSO-ChphEq3+0MwB}5;?<1E%_IeW3`&+y5L1E z!tQCb#rLWm@(Oc2$x)f(W7w&*!OmX>xggbSFqO24yB9}QbpmRIHSca#-S^^u&Ay%o31Egu=@}`WUf#_C4 zezlE+I{y zZL(ex;kv$s-5oTa-wpI|U!VdJ_LNtQfIkt&HQq1x>rM6$1voUX{(5mxlm7k!VXn7_y?&7z@%7Vx zJzc9kv-*GhapS43C_~tj*0tSOTB1|oI-TehkD<3ntDI)5_aoRp_}7=*@*OT~mf z3_1FoF=|xL4br+FJ_R{AS)UxAoVaiLXVNmfc=<2Tp*balyZsc($U$1sW6!)gJ3Zx@ z88)f@ovqKH%ZPcpu`Fk)XZW=gpD!qAlx^Z6H=3_kC7ju8>%8{m86a{#$mY0)VGl@)?iMF{&t2WB zBO%Sh4iwPyx*c$IM4)asuBlk0G-8=?H!whSFI!<}P{e2#gm%VZ&ew3LR-AA-%njAH zIe68&eQmB?*^Lu%>Z*eQ!Dy)w5rtYs1><~ly?cANf9_ixIYj_(wO&ad!h8G4EQUb9 zARC4G7;{CP#fQ0=v;`&q7P4u4%f7D6iu0A5Dy)PqgNcCViB}#1ZOWq-|Kr9fVFVj% zY3-nX0*-y%UAx?_g_*A_htB}f6K8HAsC^S%>f$*vt2fXWLAqyW{zZ|clrDw^$)46A zzHTmXMt?zC7P`_1az1RS5@+YDNOOE&RS;6r1*F{Z%9P&xxoVSTxhT~Xy>=6|7baL>03{Z-X_0m#{8c9%5LA)i07~C95r88 zV>1+W3#kXaHnZ@9zNT)t`w9+gU#+ZrZ~b%(bF~Rv08^79r2epW>HK#Joy3*~=bwV* zkf5WRGNmtIn`Z36Hs;F;sAaeADk+&ZjejkAtyv-UYzW}%bF799i)`4%G#aMZtH`DDJbhB*31IAJQcYk{bPbXj0xz@i{`dqd@p?*BL?+<{(MGlXM* zI#7npf$Ew|NWhRMO_c6BJzQYC=pG1-v!Y<(Whofgs^VqmuGS&x`$0HANg;NJT&i2} zQ>hc<43y{g<4PIg$v8OXJry?53>xelaFJH5Ob7BVgHVEMxsDKH3!vAK84>NWCXo*X z9J@yG1)m`1!fMtJ<580=`{vlQc*H)Ck|p0=WYs0kt(-lVvzJ5 zHvD`y>6pdmoP<%ufihCtg`byowsoYdUx5veYbDZf1jS&F7aSw<#T>;|F95Gi{j{Alp zxlBl7qUxEy&5j2p+rxpO$ojC+-F-ttusYB&M7$%Ac-c2S=PZe7X(_-s6@NDLLTzFn zH@ra3di$x)xaqfxry@A7oD;p^@BBHc%!LA07t8A#oja)vz7iT@nj|1g%SbhBc*v^` za@i*;tT0jFV6UA*Y`S9A%1RqZmwx9j*aaG39O(?gpqMHFDq14_$JfDempn4RsSQh# z94g@}Z)OhHyA?LtXLqCEKee?dJ;66A{@deyTXj1f;Y8`F5NTe>ay^fcq9E!cqckY) ze56rlTB|8}*1&8@{Mr2m+>0_{Q~>bgNQ$Oy_-$$mHOEmQ@wjmJc)rX^wdL~2l&JCM zbUD^;D=2|@Ao-DL>o(#U1VA)m&Fr3`c2D7psZwAqP~&>dq|oXfJTjL zEqZF4dB>uUDli8$t)m^`#dZjh3-fywSb!jXkPow42d6IzjE;`h_vzz=$*siOx`vV+ zTiWnPW1AND*Tuu&hNx;`gw3G@9QrXpf=Y|S7OyGnkHL%3g`z7XYsD$2OnKSF5)7o3 z)2}HIrz3%JP{2!Y;8Ii1-2*Zbx&PiYaaA|;lj8-PO%&!WWRA*|Ulu6wam}$pMY*6F zJ4o%=P=#jiE?t%oyqI+p+eP~>qJmyTwcTx8u8I$A&Y_(2f}_*m{#y=FcT*7zapdAQ*5oMS5aF$Q2R6|6YD2N9kYVk4pw#|a~}0IyKxxcxkV z3ld;2d->xo9{QQ9?R*<;tvN-b`XLfu8{Qe#uLn{LqKxBhX0PdpNSQ)3L!9|AwsXq0 zXNYJa0P$8eQ^rpTybMNb1049PPq5ms?H%AnjGF&zoE;=`D17Mi&Af?jEQMGjp} zoBWbci`zQ755I)aY(SAjQz&JeUQfU>Rs{7&!%aD(jK{>EERdLW|Cn0TM%fyRGSBfovjDJ>ba0j$hc(lG9@a| zN?OnxIyzXynm>!uCQo>hZr3q1W!d;+$S4p%vt$drQXb4YflUt^kt8sD8Q%-C0!wd4 zn!Ca+x`3!NLKjMDQ;^iaG^Ju1u<=u1(jEI6S-?X*>_Cx5cOYe$t#4yIVS25?-N3i6 zh1YuL;Klrfkfum5M*6q>1kGOEa$mP>I-xgRoz#Oem%a&n)96elNuJthL|*Z8JsJdw zyH0ExAdr-Hez|PWFH}`4jb1Az4XD(uG=T@H1}MKrOx@G)dyd!J>QP_~uYES@?n{l6 z)Y~)JS{)${e8Xm({ZJQF`6#^hnp~RxV0PX#py&bkrAw}q)(5#L$-CD}*r-qda7qAR z8EspOJ$CRyK&;2zF+sOr48SO?0{m8$g79Y6PzL7y-Sd)8c&jQOi57Cdw5*n;ht~8r z7_vWs2_<}#A!`1Bs`LA^)mS3$pf`hyjt8(mjGr|Id#zApM6HkAgd6`Hj)aNxzpbVZ z=e#9K+qXw7Q=2G6+!N--pM{5Q1C1z{al=7kUbjtwf0u2dqN29N&~A>jR8ra@1N{9@ z0x4xP9y3Z^KPX$*7=O(e>OXEr@|#&&p6wnV8+||0-0-onak)p3<7eYxy}l$9s}l6O zUCJXovwk63W0<(ATh8*jmY^+g4!1BQ2u`{cS22g4p}^3PbMI zU!r=~QN8JXD4xvs-qbhZJGV9m{y1Y#fxX)r5p-!ec+HIW+lsSzz(DALf3o@5)*j?p zmYBw!Wh1hnDiFj&^fCRpNf{p+yLtVZ-06jce=9fXJ0gCa$3i?kUV{X^F`WTZ%pH&4QOVp+|BnhyylpMd%Nq;HtR_cO> zn@ama{&g&}lxZA>G^S}PkFM5y)oKND;-@MQ0Fn?dkOj>uGUqBc>xk4x460YvQWdhd z4%<={NflPNQH^V?_Y5jCjZZk}c@8g268|ZzeMGqWx!+gyGfQnMfWBkN%{Q6M&q%SF z1P*NcC&UtocHhT!Xs>uv(_mCE2~tsdu~!A>ms$PFC$wxfBL?#8y-9_FjX`O+Ig3Xi z`gzfeWGJQE(PphiFkR9O)T9Hq}kr4Ga-6^BHHQ zFo88=>lbtLxK{t0YM^2kWQ-CJhK2@~B${7HI}04rXA9;n^qTG);7t=`wI0DVs;r)} zr`@Z+I#)pzP}ok7!vSfHMbh=zDU62rq&j^<<(JB%bvWNbps`Ha&f(-hnlSwkb5 zb1>}=tGmOiz`;Nn6IcVrMfGtP6k+kA%;8LNI0EpvBKYC`hDSz%tbq5{c)E8Fn|-yd zeA|W$>LiANQPb_;R8$7DxJ>pcWJFAhX*>ydTzAXT`1IgcVq!Ql!MZJ5W+djRoVk6r zR*&(A@EA;B5p7P^#P@?YlRy3TQ;Q>$w;9tLRugmPf6*p})D}KLCDyoAMw{Ox1-43^ z$E4XKmr#z~EdJ^n?4d{;=RcaF$krY+JN1Fb`uWk&EU4mVS+Bm%1A7VvF0R*l?~vkC2w@LqzuI` zHLGO|9mK=zPigPE*FnKRXu-lDE}g0}ket+_#k?vDz)D~l9SXp>_e0eN=bV9KiU!zo z`myFR5pq!hIHani7TI8FV`Y zAwk`WgP%LSKNhW47vL27tj*1Fvr{$!D&MDXHf6$|*rnETpxY(j19H6v;tGq3l8vnw zdb^Y7ZEuHJ>0tM7US>;JTYP>RFylmSzXeWL!O>B*ffqbMiX89uaXLDCiJ%puzjTlW zY$l6hpF+FPH&SLwoCFDsqSl1QOGZ#5 zx|Q&Llg@rmO&1hQxvJJ=6~lDNm*&oU6M)Mtku8Fnee|y3=pY285t;_;f8!;R!J+c+ zp-GME^&2xmqu4}*m{~d^SECPpn-741;piF({i*M5g#>AUhqN(sCAl3w#7fACnY8F# z>@z`Qgpe#Qu9IIL@Xb|&&Z+j7%!I<26C9)e<*5!eX|s3+X*@&rh-^Rf)m%+UNyW5n zrv4#3q&z|?I2hKPKm~Mt>70KYymaEHk+tG@i6Bn{6;g(qE#^vb;fEwK?Hs%<7v}9= z3LR(Isti~KDV3564LP`+P071z1&=esg8d=*t{;kih%21O?a!Lwx)K%a`PUKne>LxZ z;1{=7Mpw#Mj9+}Nx!`^zWNf0vy@&MJ6MTDinZ0a59E+raekzkq`1MHJVfb8Q(xMrn z!~6rjQFzOoA(zAZHHh?RP(wB(zL&BZ)Fe~8R~1X*ha6LXzO#-~bvYD<=S*%g;8^IF zk&zL0!HZSx3V5Z1G2y^3m$M^p?gQLK>7^0YbT}|zDhDaCPW!gwQs*bd^AmHq-oV2JWABb|30sir*%HX8vmJVTNsocp*u3O@S%^6bp$C1wI5_$?V2-))R-68dhkhmP0l)sfyih3TlFiuGYUS&dkV zpcxGa6^!obGsgVtx*j~TBM}CIm5p5Ty|g$66NFkZ+6Iu9E2}9QPzl<=g49DQozS~v zbh8*kwZ7{WK$rBhf{hp{5XcL?{0w4;t>WC(6a+6%Id^n}`-yioen|{ELJ1Ra6V?CP zMgI&aA;1!9SNNV7QA^$xnx8aElOf)VAtmZ12tmrc9Vx3NSeY*^v0Qh)=zffjUglB+ z(QJVuNdbuHQjgp)Sf z4~h=5K>-T{G=|O|V2uL14xrD@zFMJf-rGH|8O)P(2{zI_Z;iKET{a8oWLEiLLLR2z zRpA3J9hX@xltXfBD8pfy}S2a;I8_oQS7X=>!%I2L14&h8wN1$HwgeP<7am`4ERXr!iRo6=PG9z~2h zrtR`@ZvPU(sU@GqshZMZTo%j(6ds~2-S66ast-;uy-fV>ysh$1Zj z6k@~-;VFK|#ZrP>Kl9hV+^-f00^vB#IBhGq$hZ3SiZnq^dOlR7%H-C#)2?;Kc+F&jw=MYH4T%8^15 zSZFmWL|!Suu-t{v?qQmA3BzzGh@$Eei~*q(r2=)(7Tm5qKyYMOi4;w4D5y5vh$nd* z1bzK8oo@*~paTha$T#Cx@KM(@CzVuX9GFb(VCZI{_AK0>fj#I4E{uPm9u%h$*EY_n zo(e3q+zdNxK0nri;^F8nGZA?7Vc!-sa-txvGUJ;+qd69}N$&40?r8<_Xc3Uj-muIe zxws$+eor{8@2qCdufSa|kh_`CuefrmuI=_Jd(}wElvh_`hqexJ4{&*6Kjh#b8A^15 z0byhD9Qn!76?+=sp2Ry^`m+Ni1e6p5rEKKt#RFm5k>|fd51w~4ASg&#;0GN2wtLy6|r;?J!MXS6lZ2qWS ztns8*So9((*upf}lnG^_Gd-g zX-TV?dyHUDU}uV6c6Ca_g4c>F8&-sqfv^l3Bu$Y84pYCZL-t8@M_l@7R?t!+FAi6j zem6K!*Gz$ah>xYp-^L^F#d!&%LG~Xd#f8Dr+*OBHF&HcVg-e#<{1I@1cI)ICh1eUP zWaaW}HbVsI%3pzYn&Wrs0p{e@6;o8T*Sj5iLYE>_(pUB}MYhRMJp)ha?~-=`@$w~I zjfkque4WX^NQgt){Pn_~L@6@$bIfa3U-=qrF^|lF=;-t$?2d*%G0}t23@Tww0W_r3 z7EC+-!S>iDxG7~3>XD<->S(_a1*d(dE2KN(Di9wPqPmaXgx{oY;SpsH1(&fQ4zS{8a7LQFwOoo2 z8Z{m%BftIz=?(CN;q(xNvHvh?M2m(_1=x~~=u1QYP$Wvis<{s)Q$BApq$`>@fcPKl1Oa`Clm;P z$l)nkym`KU9GTvN48KjmSTbCac;U?XIWTqUJWslC0ItV^dK-yy_lJ=Ms?7k zLp5$2YgsVHvU1{6kG?(41hy;h&L($NtXvXl(P%rtsztc|(Q(B>mVXzeBh&8(L z41LAPK0UifY>vb&2nQ>hKWdnfv@7%@CzXSjLxCF3g|9&z98sV!_cLFxRwf2~^gPEe zs7U{Xj0t`ni@Jm#luq&I3CIR3xWxZKKkarz34+Pr)Jx+jA3ab=2uUGjPQ*ShEP=ht z|GK4wMWoe(^@B}Ps}CKbXi4KQ;VYwHsiw~9hjG>H4kO;UNaH`EsU z_+3EUWVnwcAfyUdQmWmd83! zNM-v8p!Nq+aUjj2WE9sw0sL4r3lr=VPI_V6C%{a>H#I=bVPcnYH$=^$V&s_U1A+{# z(yj-9AVoc!jBo+JfhE?xXaT>%t~E}KKtOc0`3Qa>K*qn>p5pTqu;J`4j?Z!-xZ{rW z|E~sR=Aoktcbk}4;<*un+}qofYF*7&Xd9Zs=}sdnZ+(%#57C-uO&j#}MQv)K2o~a& zkGIFvnt21>Pz}nEi%^Um2zZ$$b-G)O7-8*-{8al*chY;OYrbA9O|>qXprn1VkLfc& z)PQ2uiyd9ldVTF1yJ+%9jI_mmU-+Ao8)?Y1Am(@DrG0;&_2&Z88-^CEaq}uIOx}-I zAXveJ&%;IUR;+ogJVcPRVO?2323{0I2Dy}MescbbU9mM-jup$(DGN)O&+9q4o;;yP zxu><;qmK1TLolMOk+*phURF{u5VaQHS(+Co4}*&{0LHrnqCs{E-7 zfkwFOJgux^mu<|~blQOgDE0(bSI3kvX70>hdk6;!neeOPULp-QHR&}out@Q>EnW67 zNBH2D+&Kefn|0|&i2GOud(uo!{K35jlE}iM+U-NahV9X_!dJ`)s-qVJDwaGbT77#l z*Uc1hWK1anY*Kb{O7tFQL9rrA^lH4qP?R`goqn32TZoY9d0Ef~xiHw%C0h2=xB z@VU4K7z$lO91rZxvTQqY^grJUc_(aI^Pmo3maS3~UV~U5^L^l4RS6)w$d+zqu;jGb zn4Wj6e9$W)Rz8dBX46fzo*}PKJp_4pg{{(k`C@}2?d_Sg_pT7@yzNm2{H7y{qqtyki`Q# zHd#6Thlar@sP;ImA)`>cLuB^HB{<~^tx>W~MAWWW(A%XnxLle8g7w6au=TkXVFG@l zLu(>#LcfwF%6wYY_91rWk4Zn@OmV(y+nGZIUt=;Vd+`_WoA1AgQDlL&8~i+92m=kK zf<4JCTd~Ec^ld&!MP`ioINJJxvuOO$A19ev1VfhjsUi}@3m%5+G`!y+!nKwa*BdY? z^U91bQ$X(%4e z5E=^4Q2QfV?MpwQJ5}Qm z_`opePY@tdpn2w2?{wbRcl((;&!AmND6n+TY(efX$sou3vD)M`yOBz!a&+P~^ZKF) zU~9+L@nuyj*@rtr4jXH+Ttq9{EZN)oCChFy?+mAI4co{2WWj_G8k{2T^VtTzVWH|C z9Sl}D67~50d)qymC^0?@jcjXh39imezdG-&u*xsLpU!GG{a-jjdA2IGSM;yT_(}tz z(4qw(Oe|i%^rBqJWqAJ9{$|7V#UE48UsbKx%%So)$bAQyE6MD4_~O{AK?5TjLGO?D z#{SIkgSd$5a7-~_5QJ;j*5#6E=)u7tIwA`XT5{z)VYX~PrJ`AOfTs7mi>(J=?+A}} z&g{jvEh|FsUouyzPHts`$J^u()_3XZbR^b(*m3n4@*;o;h)TA4dA3yloyWvxIbRk) z4dUOQH>u}B)*Q02&As%Yg9{xjHE+2M_@)#TmE+K@UQsNY&)pb}1N7sJPUl`#Y)$i? zH@!@)tNX~DFEO^6#4^a4>1NAB;$wv2Pu(bH;Jgh=MEwdi5dl8puL}W$BDO{D|=7u~YG}qdgvQDf+}o;|Q#D z<7bod0?MU#klEoW&NzSe)@^yjn-2y_<%DEU87_pfb5H3?;j#7IqKvzs2h2)ad`DbL zXOt+Rj^<*AO4m5WHF6`{)RwXE7BZK$tR+ccY2~jN6NPEShh-za@5!QA_I!Z^2Z?%( zG#62it2|rQKKAEwiX)wZ6v{&KXpN;%VGGRzR-{ly)fp+0UriKn({r?kFB&;K&Wiyk)xxS)+!;HodlK1%E_ZXy>^;;sc@Y2A7)jL9FLI5HR9W*b zod@MUoC?77E&Gd5`d=X!8kECcn~fh04^Vr>O2wnvK7$M1bOgC|4bgzlkfntCJe#|d()m@k z3ys-n{Ln8v)WBH-8$lAK6xKX1NgSlMA0^bPiHL=@PXmz40Ybv8(L*wu6mVHUhIp$; zG_#d|`RBi+A_W|mRzs8>FT9#Gp{Nx3zHW(x@9zha5j z$K!N#!cLv?cX7?l1Zwb0@E_dVIHvF0@SN5<=}WEyzVjPqYGX~-Q2+z9ohiZ8;gO-` z)J7k-^PaN6r*h5!l^2;ihdQrbQ}$%KxjiIjPaJ#A@3iU0smGIb5;zaeXn+BQ+$d|V zHhzv@8Tb;rvrig0C#WEDO7cRr8|G?A3fO@UscR)+#y^Q@s5#;K{4fJpd*p3I7$>3v zktHL=f_VwrJK@U){tGv{UF9(C8U@gh#dD2U$%)?v#@m5a4N|rb3fD;26`!vOQsfH& n0s)p$U3jLpb^l-3#|KznG3~5aP}4uJF#{w-WQG3->IM8i+i@md literal 0 HcmV?d00001 diff --git a/docs/verification/ui-redesign-toss/t06a-home-banner-cancel-cta.png b/docs/verification/ui-redesign-toss/t06a-home-banner-cancel-cta.png new file mode 100644 index 0000000000000000000000000000000000000000..8a27baf41c257cd5d18756ca266117a91119e972 GIT binary patch literal 16941 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 Date: Mon, 24 Aug 2026 10:27:44 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20AtchaV2=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EC=95=A1=ED=8B=B0=EB=B9=84=ED=8B=B0=20=EA=B3=A0?= =?UTF-8?q?=EB=8F=84=ED=99=94=20=E2=80=94=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=ED=9E=88=EC=96=B4=EB=A1=9C=20=EC=9E=A0=EA=B8=88=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20+=20DI=20=EC=A0=84=EB=A9=B4=20Pretendard=20(?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EB=8B=A8=20=EC=A0=84=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 잠금화면: 3행 나열 → 타이머 히어로 4단 위계(헤더/오버라인/대형 카운트다운 38pt/메타). 타이머 아이콘 제거 — 긴급도는 숫자 색이 전달, 아이콘 앵커는 헤더 수단 심벌. stale·final은 heroMessage 공용 헬퍼로 히어로 자리 차지 - ⚠ 당겨짐 배지: solid red400 → 홈 배너 caution 티어(danger 글자 + dangerContainer 채움) — 강한 긴급색은 히어로 숫자 몫 - DI: expanded bottom 미니 히어로(오버라인+28pt), leading/trailing/compact Pretendard 통일, compactTrailing maxWidth 56 등 기존 수정자 세트 보존, expanded .center 미사용 사유 주석화. keylineTint·배경 틴트 불변 - Pretendard 브리지(DSFont+SwiftUI) 신규 — DSFont.pretendard 기반이라 once-가드 등록+가중치 보존 폴백 내장, WidgetBundle init에서 이른 등록 - #Preview 픽스처 9종(활성 3단계·배지·final 3종·DI expanded/compact/minimal· stale 직접 렌더) — 빌드에서 컴파일 검증 - 계약(LastTrainActivityAttributes)·어댑터·카피·파생 헬퍼 무수정 - 시뮬 검수: 잠금화면 활성/배지/운행종료 + 컴팩트 DI 증적 docs/verification/la-redesign (DI 실물·실기기 타이머 폰트는 실기기 잔여) Co-Authored-By: Claude Fable 5 --- .../Widget/Sources/AtchaWidgetBundle.swift | 9 + .../App/Widget/Sources/DSColor+SwiftUI.swift | 5 +- .../App/Widget/Sources/DSFont+SwiftUI.swift | 17 ++ .../Sources/LastTrainLiveActivityWidget.swift | 246 ++++++++++++++---- .../la-redesign/la-01-lock-active.png | Bin 0 -> 46120 bytes .../la-redesign/la-02-lock-badge.png | Bin 0 -> 50129 bytes .../la-redesign/la-03-lock-serviceend.png | Bin 0 -> 51372 bytes .../la-redesign/la-04-di-compact.png | Bin 0 -> 8414 bytes 8 files changed, 220 insertions(+), 57 deletions(-) create mode 100644 Projects/App/Widget/Sources/DSFont+SwiftUI.swift create mode 100644 docs/verification/la-redesign/la-01-lock-active.png create mode 100644 docs/verification/la-redesign/la-02-lock-badge.png create mode 100644 docs/verification/la-redesign/la-03-lock-serviceend.png create mode 100644 docs/verification/la-redesign/la-04-di-compact.png diff --git a/Projects/App/Widget/Sources/AtchaWidgetBundle.swift b/Projects/App/Widget/Sources/AtchaWidgetBundle.swift index a83f4274..13cc7f2e 100644 --- a/Projects/App/Widget/Sources/AtchaWidgetBundle.swift +++ b/Projects/App/Widget/Sources/AtchaWidgetBundle.swift @@ -1,8 +1,17 @@ +import DesignSystem import SwiftUI import WidgetKit @main struct AtchaWidgetBundle: WidgetBundle { + // 익스텐션 프로세스 시작 시점에 Pretendard를 미리 등록한다 — WidgetKit이 + // 스냅샷을 요청하기 전에 등록 비용이 끝나도록. once-가드라 멱등이고, + // Font.pretendard 경로도 내부에서 같은 등록을 지연 수행하므로 이 훅은 + // 타이밍 보증일 뿐 정합성 조건은 아니다. + init() { + DSFont.registerFontsIfNeeded() + } + var body: some Widget { LastTrainLiveActivityWidget() } diff --git a/Projects/App/Widget/Sources/DSColor+SwiftUI.swift b/Projects/App/Widget/Sources/DSColor+SwiftUI.swift index 2a462260..b77df406 100644 --- a/Projects/App/Widget/Sources/DSColor+SwiftUI.swift +++ b/Projects/App/Widget/Sources/DSColor+SwiftUI.swift @@ -3,8 +3,9 @@ import SwiftUI import UIKit // DesignSystem is UIKit-based (UIColor role tokens). The widget extension is -// the repo's only SwiftUI surface, so the bridge lives here instead of adding -// a SwiftUI dependency to DesignSystem itself. +// the repo's only SwiftUI surface, so the bridges (this color bridge and the +// DSFont+SwiftUI font bridge) live here instead of adding a SwiftUI dependency +// to DesignSystem itself. extension Color { /// Bridges a DesignSystem `DSColor` token (UIColor) into SwiftUI, /// e.g. `Color(ds: DSColor.Accent.default)`. diff --git a/Projects/App/Widget/Sources/DSFont+SwiftUI.swift b/Projects/App/Widget/Sources/DSFont+SwiftUI.swift new file mode 100644 index 00000000..344eea50 --- /dev/null +++ b/Projects/App/Widget/Sources/DSFont+SwiftUI.swift @@ -0,0 +1,17 @@ +import DesignSystem +import SwiftUI +import UIKit + +// DSColor+SwiftUI와 같은 독트린 — 브리지는 위젯에 두고 DesignSystem에는 +// SwiftUI 의존을 더하지 않는다. +extension Font { + /// DesignSystem Pretendard를 SwiftUI로 브리지한다, + /// e.g. `Font.pretendard(.bold, size: 38)`. + /// DSFont.pretendard가 최초 호출 시 once-가드 등록을 수행하고, 번들·등록 + /// 실패 시 같은 가중치의 시스템 폰트로 폴백한다 — Font.custom을 쓰지 않는 + /// 이유(커스텀 이름 실패 시 weight를 잃은 시스템 regular로 떨어진다). + static func pretendard(_ weight: DSFont.Weight, size: CGFloat) -> Font { + // UIFont ↔ CTFont는 톨프리 브리지 — SwiftUI 공개 API Font(_: CTFont)로 감싼다. + Font(DSFont.pretendard(weight, size: size) as CTFont) + } +} diff --git a/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift index 5f4dce13..3d6da9d4 100644 --- a/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift +++ b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift @@ -36,22 +36,26 @@ struct LastTrainLiveActivityWidget: Widget { DynamicIslandExpandedRegion(.leading) { HStack(spacing: DSSpacing.xs) { Image(systemName: context.attributes.transportSymbolName) - .font(.subheadline) + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(state.glanceColor) Text(context.attributes.routeName) - .font(.headline) + .font(.pretendard(.semiBold, size: 15)) .foregroundStyle(Color(ds: DSColor.Text.primary)) .lineLimit(1) } } DynamicIslandExpandedRegion(.trailing) { Text("\(LastTrainTimeFormat.hhmm(state.departureTime)) 출발") - .font(.subheadline) + .font(.pretendard(.regular, size: 13)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) .lineLimit(1) } + // expanded .center는 의도적 미사용 — 센서 하우징 좌우 클램프 탓에 + // 와이드 타이머가 잘리거나 축소되고, 전폭을 쓸 수 있는 영역은 + // bottom뿐이다. leading/trailing이 채워진 구성에선 center가 + // 세로 밴드만 더해 160pt 확장 캡을 압박한다. DynamicIslandExpandedRegion(.bottom) { - // 잠금화면 2행의 축약판. 도보 안내 축약도 이 영역 몫이지만 + // 잠금화면 히어로 슬롯의 축약판. 도보 안내 축약도 이 영역 몫이지만 // ContentState에 도보 필드가 없어 생략 — 아래 // LastTrainLockScreenView.departureRow 주석 참고. switch state.status { @@ -59,26 +63,26 @@ struct LastTrainLiveActivityWidget: Widget { // 갱신이 끊긴 채 staleDate(=출발 시각)가 지났다 — 얼어붙은 // 카운트다운 대신 최신성 경고 (Phase 13, 앱 깨움 없는 마지막 방어선). Text(LastTrainStaleCopy.message) - .font(.headline) + .font(.pretendard(.semiBold, size: 15)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) + .lineLimit(1) + .minimumScaleFactor(0.8) case .active: - HStack(alignment: .firstTextBaseline, spacing: DSSpacing.sm) { + VStack(alignment: .leading, spacing: DSSpacing.xxs) { Text("출발까지") - .font(.subheadline) + .font(.pretendard(.medium, size: 12)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) - HStack(spacing: DSSpacing.xs) { - Image(systemName: "timer") - .font(.body.weight(.semibold)) - Text(timerInterval: state.countdownRange, countsDown: true) - .font(.title2.weight(.bold)) - .monospacedDigit() - .multilineTextAlignment(.leading) - } - .foregroundStyle(state.urgencyColor) + Text(timerInterval: state.countdownRange, countsDown: true) + .font(.pretendard(.bold, size: 28)) + .monospacedDigit() + .foregroundStyle(state.urgencyColor) + .lineLimit(1) + .minimumScaleFactor(0.6) + .multilineTextAlignment(.leading) } case .departed, .missed, .serviceEnded: Text(state.finalStatusMessage ?? "") - .font(.headline) + .font(.pretendard(.semiBold, size: 17)) .foregroundStyle(state.glanceColor) } } @@ -90,11 +94,11 @@ struct LastTrainLiveActivityWidget: Widget { switch state.status { case .active where isStale: Text("지남") - .font(.caption2.weight(.semibold)) + .font(.pretendard(.semiBold, size: 12)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) case .active: Text(timerInterval: state.countdownRange, countsDown: true) - .font(.caption2.weight(.semibold)) + .font(.pretendard(.semiBold, size: 12)) .monospacedDigit() .foregroundStyle(state.urgencyColor) .multilineTextAlignment(.trailing) @@ -105,15 +109,15 @@ struct LastTrainLiveActivityWidget: Widget { .frame(maxWidth: 56) case .departed: Text("출발") - .font(.caption2.weight(.semibold)) + .font(.pretendard(.semiBold, size: 12)) .foregroundStyle(state.glanceColor) case .missed: Text("놓침") - .font(.caption2.weight(.semibold)) + .font(.pretendard(.semiBold, size: 12)) .foregroundStyle(state.glanceColor) case .serviceEnded: Text("종료") - .font(.caption2.weight(.semibold)) + .font(.pretendard(.semiBold, size: 12)) .foregroundStyle(state.glanceColor) } } minimal: { @@ -127,16 +131,18 @@ struct LastTrainLiveActivityWidget: Widget { // MARK: - Lock screen -/// 잠금화면 뷰 — UX 정본 목업: +/// 잠금화면 뷰 — 타이머 히어로 목업(UX 정본): /// ``` /// ┌────────────────────────────┐ /// │ 🚌 5518 막차 [⚠ 당겨짐] │ -/// │ 출발까지 ⏱ 22분 │ ← 여유도에 따라 색 변경 +/// │ 출발까지 │ +/// │ 21:34 │ ← 히어로 카운트다운, 여유도에 따라 색 변경 /// │ 23:25 출발 · 정류장 도보 8분 │ /// └────────────────────────────┘ /// ``` -/// glance는 0.5초 — 숫자보다 색·상태가 먼저 읽히도록 카운트다운 -/// 숫자·아이콘에 긴급도 색을 상시 적용한다. +/// glance는 0.5초 — 숫자보다 색·상태가 먼저 읽히도록 카운트다운 숫자에 +/// 긴급도 색을 상시 적용한다. 타이머 아이콘은 히어로 스케일에서 숫자와 +/// 경합해 제거 — 아이콘 앵커는 헤더의 수단 심벌이 담당한다. private struct LastTrainLockScreenView: View { let attributes: LastTrainActivityAttributes let state: LastTrainActivityAttributes.ContentState @@ -147,7 +153,7 @@ private struct LastTrainLockScreenView: View { var body: some View { VStack(alignment: .leading, spacing: DSSpacing.sm) { headerRow - mainRow + heroSlot departureRow } .padding(DSSpacing.md) @@ -157,10 +163,10 @@ private struct LastTrainLockScreenView: View { private var headerRow: some View { HStack(spacing: DSSpacing.xs) { Image(systemName: attributes.transportSymbolName) - .font(.subheadline) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color(ds: DSColor.Icon.default)) Text("\(attributes.routeName) 막차") - .font(.subheadline.weight(.semibold)) + .font(.pretendard(.semiBold, size: 14)) .foregroundStyle(Color(ds: DSColor.Text.primary)) .lineLimit(1) Spacer(minLength: DSSpacing.sm) @@ -170,46 +176,62 @@ private struct LastTrainLockScreenView: View { } } - /// 2행: glance 핵심. active면 카운트다운(긴급도 색), stale이면 최신성 경고, - /// departed/final state면 카운트다운을 숨기고 상태 문구만 표시. + /// 2행(히어로 슬롯): active면 오버라인 + 대형 카운트다운(긴급도 색), stale이면 + /// 최신성 경고, final state면 상태 문구가 히어로 자리를 차지한다. @ViewBuilder - private var mainRow: some View { + private var heroSlot: some View { switch state.status { case .active where isStale: // 갱신이 끊긴 채 staleDate(=출발 시각) 경과 — 얼어붙은 카운트다운을 // 신선한 정보처럼 보여주지 않는다 (Phase 13 마지막 방어선). - Text(LastTrainStaleCopy.message) - .font(.title3.weight(.bold)) - .foregroundStyle(Color(ds: DSColor.Text.secondary)) + heroMessage( + LastTrainStaleCopy.message, + font: .pretendard(.bold, size: 20), + color: Color(ds: DSColor.Text.secondary), + lineLimit: 2 + ) case .active: - HStack(alignment: .firstTextBaseline, spacing: DSSpacing.sm) { + VStack(alignment: .leading, spacing: DSSpacing.xxs) { Text("출발까지") - .font(.body) + .font(.pretendard(.medium, size: 12)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) - HStack(spacing: DSSpacing.xs) { - Image(systemName: "timer") - .font(.title3.weight(.semibold)) - // 알람 발화 시각(alarmTime = 출발 기준시각 − 버퍼) 기준 — - // 정책: "출발까지 N분"은 버퍼 포함 알람 시각 기준으로 통일. - Text(timerInterval: state.countdownRange, countsDown: true) - .font(.title.weight(.bold)) - .monospacedDigit() - .multilineTextAlignment(.leading) - } - .foregroundStyle(state.urgencyColor) + // 알람 발화 시각(alarmTime = 출발 기준시각 − 버퍼) 기준 — + // 정책: "출발까지 N분"은 버퍼 포함 알람 시각 기준으로 통일. + // h:mm:ss 최악 8글리프도 38pt에서 폭 여유가 있지만 + // lineLimit+minScale을 벨트로 유지한다. + Text(timerInterval: state.countdownRange, countsDown: true) + .font(.pretendard(.bold, size: 38)) + .monospacedDigit() + .foregroundStyle(state.urgencyColor) + .lineLimit(1) + .minimumScaleFactor(0.6) + .multilineTextAlignment(.leading) } case .departed, .missed, .serviceEnded: - Text(state.finalStatusMessage ?? "") - .font(.title3.weight(.bold)) - .foregroundStyle(state.glanceColor) + heroMessage( + state.finalStatusMessage ?? "", + font: .pretendard(.bold, size: 22), + color: state.glanceColor, + lineLimit: 1 + ) } } + /// stale·final 공용 — 히어로 자리를 차지하는 상태 문구. + private func heroMessage( + _ text: String, font: Font, color: Color, lineLimit: Int + ) -> some View { + Text(text) + .font(font) + .foregroundStyle(color) + .lineLimit(lineLimit) + } + /// 3행: 출발 시각 + 정류장 도보 소요 (Phase 14 — 미확정 #7 클라 임시안으로 목업 공석 해소). /// 도보 데이터가 없는 세션(구버전 LA·도보 없는 경로)은 출발 시각만 표시한다. private var departureRow: some View { Text(departureRowText) - .font(.footnote) + .font(.pretendard(.regular, size: 13)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) .lineLimit(1) } @@ -239,14 +261,16 @@ private extension LastTrainActivityAttributes { /// "⚠ 당겨짐" 배지 — 변경 직후 10분 노출 정책의 표시 슬롯. /// 취소선 등 변경 흔적의 상시 표시는 하지 않는다(정책). +/// 스타일은 홈 배너 caution 티어(danger 글자 + dangerContainer 채움)와 같은 +/// 은은한 채움 — 강한 긴급색은 히어로 숫자가 담당한다. private struct ChangeBadge: View { var body: some View { Text("⚠ 당겨짐") - .font(.caption2.weight(.semibold)) - .foregroundStyle(Color(ds: DSColor.Text.primary)) + .font(.pretendard(.semiBold, size: 12)) + .foregroundStyle(Color(ds: DSColor.State.danger)) .padding(.horizontal, DSSpacing.sm) .padding(.vertical, DSSpacing.xxs) - .background(Color(ds: DSColor.State.danger), in: Capsule()) + .background(Color(ds: DSColor.State.dangerContainer), in: Capsule()) .lineLimit(1) } } @@ -323,3 +347,115 @@ private enum LastTrainTimeFormat { formatter.string(from: date) } } + +// MARK: - Previews (렌더 검증 픽스처 — 계약·정책과 무관한 표시 확인용) + +private extension LastTrainActivityAttributes { + static let previewSubway = LastTrainActivityAttributes( + routeId: "preview.subway", + routeName: "9호선 급행", + transportKind: .subway, + firstWalkSeconds: 300 + ) + + static let previewBusNoWalk = LastTrainActivityAttributes( + routeId: "preview.bus", + routeName: "5518", + transportKind: .bus, + firstWalkSeconds: nil + ) +} + +private extension LastTrainActivityAttributes.ContentState { + /// alarm이 N초 후, 출발은 alarm+3분 — 미래 시각 기반이라 프리뷰 타이머가 실제로 틱한다. + static func preview( + alarmIn seconds: TimeInterval, + urgency: LastTrainUrgency, + badge: Bool = false, + status: LastTrainSessionStatus = .active + ) -> Self { + let alarm = Date.now.addingTimeInterval(seconds) + return .init( + departureTime: alarm.addingTimeInterval(180), + alarmTime: alarm, + urgency: urgency, + changeBadgeExpiry: badge ? Date.now.addingTimeInterval(600) : nil, + status: status + ) + } +} + +#Preview("lock-active-relaxed", as: .content, using: LastTrainActivityAttributes.previewSubway) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 22 * 60, urgency: .relaxed) +} + +#Preview( + "lock-active-caution-badge", as: .content, using: LastTrainActivityAttributes.previewSubway +) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 7 * 60, urgency: .caution, badge: true) +} + +#Preview( + "lock-active-imminent", as: .content, using: LastTrainActivityAttributes.previewBusNoWalk +) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 45, urgency: .imminent) +} + +#Preview("lock-final-states", as: .content, using: LastTrainActivityAttributes.previewSubway) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState + .preview(alarmIn: -60, urgency: .imminent, status: .departed) + LastTrainActivityAttributes.ContentState + .preview(alarmIn: -300, urgency: .imminent, status: .missed) + LastTrainActivityAttributes.ContentState + .preview(alarmIn: -300, urgency: .relaxed, status: .serviceEnded) +} + +#Preview( + "di-expanded", as: .dynamicIsland(.expanded), + using: LastTrainActivityAttributes.previewSubway +) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 22 * 60, urgency: .relaxed) + LastTrainActivityAttributes.ContentState.preview(alarmIn: 45, urgency: .imminent) + LastTrainActivityAttributes.ContentState + .preview(alarmIn: -300, urgency: .imminent, status: .missed) +} + +#Preview( + "di-compact", as: .dynamicIsland(.compact), + using: LastTrainActivityAttributes.previewBusNoWalk +) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 7 * 60, urgency: .caution) + LastTrainActivityAttributes.ContentState + .preview(alarmIn: -300, urgency: .imminent, status: .missed) +} + +#Preview( + "di-minimal", as: .dynamicIsland(.minimal), + using: LastTrainActivityAttributes.previewSubway +) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 22 * 60, urgency: .relaxed) +} + +// 프리뷰 매크로는 context.isStale을 주입할 수 없다 — stale 분기는 뷰 직접 렌더로 확인. +#Preview("lock-stale") { + LastTrainLockScreenView( + attributes: .previewSubway, + state: .preview(alarmIn: -300, urgency: .caution), + isStale: true + ) + .background(Color(ds: DSColor.Background.base)) +} diff --git a/docs/verification/la-redesign/la-01-lock-active.png b/docs/verification/la-redesign/la-01-lock-active.png new file mode 100644 index 0000000000000000000000000000000000000000..93ba1e7b4af4ccec91ebd926a08ed29d52767816 GIT binary patch literal 46120 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

rMiX4k#U`Q?CUe&Yu^7$O2x@Rql2g z%(uZ$sB5+;W|p2?P_qluHuEwpu{vW5nOO;)t?(qCGwDWQTZQhbPCmr3mw7PKBN$(7 zd4|KUT=wf^ykUf=m(S1dO^3Wg=<1b%`LSyq*iYB?dx;Ktuu?g^#0JOsNHq^xGP+Lq znu#>Km;69Bbv}||ei<1%jKn?m%Xj!_wt;i`&_LArye);7VR_E0db-(PYk9+(t=MF} ziztId{cK`CH+sh>cL_XSfyX`b_=-9<2={GwY%>bhV@R_gJ>{wTt+c85=*{TWs%>_x z8vPVh;L$NN0bMoM$2uOMGk}3xGXOdN%HjWlv|@BY>o!Op z)${F^b-lD*Lq>TKmQs3NYNg(%e`ihS!ipD|A`g>PLjVg-JipNDFr9pUjw8wUd1P7& zXLffD&PI-p2LwaDX+*G`GlCy7Jv#y)@SZ}QwqJ8!sJQPXZ(E+!1}u)k{X5 zbWZv1a&~r{6+k7x_JQBMcUpm@irOG{pp4U^S0n(oIpyw4)dZ%Cx-?Bp?$A2ooozVF z#y`6q6_&a z>CHe$Usldj*C?Dd4}_Wm`3z02r=Y8OW`j%%)i%3SUKH$u}s90Br zxjPi{`WMgrGb4E-sMY}T5EJ@AlgiUJg}%aA6M9cz3^IUoFIM<7tP78E9LToYHgcmp zJu=qUG|guv;|WDa?v?9D(|`F7es=or{@Y*50C)oGg0=P4YI1de_~Dg-~W%l zI{gp-(?6YV-aN*c1TMNX2iv>sOceVJ?|Zbd<5@pe{_d)F*$w!@OJ9NO)RG0^hCR1c z9i0xpMe~A=wpqA)npD(iG;Z^Ito~$NSrr#b76ec*+5=xHWy@2OEFe0*cQ*ZR|Hr?Y zzV@fD(RmE{v{b^Mh!J&Gt=sh|3~+&0sq3AKv6KM`0`~nEh7Ehb0^<+A`km-rT?U`V z^|U`zNu=H&X8Na(ype|>0B8_U@fEKX1VJqPnE{37F~cr0#6_?yHJYl`zZfxCjk3}G zHU73HO<)k9w^n#+TJsXoN6a5g-}vU6`E~p}iAx!{d33%wwCLRSZ)sE7<9ruKFSL!f zv~ALQXecu_s({j|uK5?UYM0oK@t=u;X+MX4dGi+m&rwd+Ir0+0WO+Nu^CEcgp4iYc zz?$2(cI&o_%A5F_CE_-AQl;HfbqwnTqqr{Hl-R`V1s=+b&Xv%Wf+8)(uZ9i64N!W# z?EnA)4@pEpRNPH>_9`CwIcOJ*83cz7j_44q=#omcS8*k8&|#wv>UhtJ(|u+Eau$D% z{LFm9YEzM>sXi|N<61zllp6$`heR^vjiEB(vS(gx+= zyCO_+Asq%=A%wo1yFeF$f>}413ofi67^8xa*BP>e9$O69F5i{~WEb z;!hjfK6(kHfGaLtd}2c*EO}5p6ZoEKrG0R>>njGUrW9B8!ioz=r&e@>@0dHb8%8Uc z{5q}6z#dz|(7uJzlrwz0J@sv%yaz=$buPn;$~{AD=mg?Meb%OUw4+y%z-$1L>mCpN n#_V6ldzz!S+qUcSTwwn{h7oWeM<;sZ00000NkvXXu0mjf-=YJb literal 0 HcmV?d00001 diff --git a/docs/verification/la-redesign/la-03-lock-serviceend.png b/docs/verification/la-redesign/la-03-lock-serviceend.png new file mode 100644 index 0000000000000000000000000000000000000000..80c12d066f562a6734533627f8259ae3283379d1 GIT binary patch literal 51372 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

i}U0$jXt> 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 literal 0 HcmV?d00001 diff --git a/docs/verification/la-redesign/la-04-di-compact.png b/docs/verification/la-redesign/la-04-di-compact.png new file mode 100644 index 0000000000000000000000000000000000000000..e37fc867d54c1e466aeb1df30fc8a7b9878e1d1f GIT binary patch literal 8414 zcmc(l^-~;8;O0pPkf0%i#R3Eit_hMQK!C;F7GHw9y9W~7b#Vd&f-dgv794hQch}(T z?R)nJ+z)qE->#ajscEb3p6+@2`AnFyq7)uBIW`&^8lH?aNEHq3xi0EH@FgZHcC^N8 zM?-s`F9Q-)_sBR}dgY8gp@tEt!~qbl?tg_o7|s5d$V1o-Kqbb7{l6DA;lD^H;tlRKnn-s<_DJ~% z^-xHG=xHJ4n)ZE9M#Px6gRwW`|MG#VNjs%jgA^$T>w#9OK= zD$I87wbStPZ<3�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 j9W*q(#ad;e&XLdI4+iv()mf Date: Sun, 13 Sep 2026 13:02:13 +0900 Subject: [PATCH 03/13] =?UTF-8?q?refactor:=20CoreAuth=20=EC=9D=B5=EB=AA=85?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=A0=84?= =?UTF-8?q?=EC=A0=9C=EC=9D=98=20=EC=84=B8=EC=85=98=20=EC=88=98=EB=AA=85=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnonymousSessionIssuing/UnconfiguredAnonymousSessionIssuer 삭제 (TokenPair는 TokenPair.swift로 이동) - AuthSessionManager: bootstrapState()(동기 판정) + adopt(토큰 채택) + clearSession + sessionExpired 스트림 - performRecovery: refresh 확정 거절(비-SUCCESS/401)만 세션 사망 — 토큰 클리어 + loginRequired throw + sessionExpired yield, 일시 장애는 토큰 보존·propagate - AuthError: issuerNotConfigured → loginRequired - AppCoordinator: bootstrapState 분기(loginRequired는 임시 재시도 문구 — AuthFeature 통합 커밋에서 교체) - 테스트 30케이스 재정렬: bootstrapState/adopt/clearSession/확정거절·일시장애 케이스 신규, issuer 폴백 케이스 제거 Co-Authored-By: Claude Fable 5 --- Projects/App/Sources/AppCoordinator.swift | 30 ++-- Projects/App/Sources/AppDIContainer.swift | 5 +- .../App/Sources/BootstrapFailureMessage.swift | 4 +- .../Sources/AnonymousSessionIssuing.swift | 27 ---- Projects/Core/Auth/Sources/AuthError.swift | 8 +- .../Auth/Sources/AuthSessionManager.swift | 100 ++++++++---- Projects/Core/Auth/Sources/TokenPair.swift | 9 ++ .../Auth/Tests/AuthSessionManagerTests.swift | 153 +++++++++--------- Projects/Core/Auth/Tests/AuthTestStubs.swift | 16 -- .../AuthenticatedNetworkClientTests.swift | 57 ++++--- 10 files changed, 193 insertions(+), 216 deletions(-) delete mode 100644 Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift create mode 100644 Projects/Core/Auth/Sources/TokenPair.swift diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index 7b6d331a..a5462e2a 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -10,17 +10,12 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { private let container: AppDIContainer private weak var splashViewController: SplashViewController? - private var bootstrapTask: Task? init(navigationController: UINavigationController, container: AppDIContainer) { self.navigationController = navigationController self.container = container } - deinit { - bootstrapTask?.cancel() - } - func start() { let splash = SplashViewController() splash.onRetryTapped = { [weak self] in self?.bootstrap() } @@ -30,23 +25,16 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { } private func bootstrap() { - bootstrapTask?.cancel() splashViewController?.showLoading() - bootstrapTask = Task { [weak self] in - guard let self else { return } - do { - try await self.container.authSessionManager.bootstrap() - guard !Task.isCancelled else { return } - self.startHome() - // 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만. - self.container.alarmSyncService.activate() - } catch { - guard !Task.isCancelled else { return } - // 원인별 문구 분기(Phase 16) — 오프라인만 구분, 그 외는 일시 장애 안내. - self.splashViewController?.showRetry( - message: BootstrapFailureMessage.text(for: error) - ) - } + // 세션 판정은 동기(키체인 존재 여부) — 토큰 유효성은 첫 인증 요청이 증명한다. + switch container.authSessionManager.bootstrapState() { + case .active: + startHome() + // 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만. + container.alarmSyncService.activate() + case .loginRequired: + // 임시(커밋 1): AuthFeature 통합 커밋에서 로그인 시트 present로 교체. + splashViewController?.showRetry(message: "로그인이 필요해요") } } diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 7574ef8f..a6f8f64f 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -70,10 +70,7 @@ final class AppDIContainer { tokenStore: TokenStore(store: KeychainStore()), // The plain client, not the decorator — reissue must never recurse // into the 401-recovery path. - networkClient: baseClient, - // 미확정 입력 #2: swap in the real issuer here once the anonymous - // issuance endpoint spec is confirmed. - issuer: UnconfiguredAnonymousSessionIssuer() + networkClient: baseClient ) self.authSessionManager = sessionManager let networkClient = AuthenticatedNetworkClient( diff --git a/Projects/App/Sources/BootstrapFailureMessage.swift b/Projects/App/Sources/BootstrapFailureMessage.swift index ac3b0e6f..f1bcf071 100644 --- a/Projects/App/Sources/BootstrapFailureMessage.swift +++ b/Projects/App/Sources/BootstrapFailureMessage.swift @@ -2,8 +2,8 @@ import CoreNetwork import Foundation /// 스플래시 부트스트랩 실패 문구 매핑(Phase 16) — 원인 불문 고정 문구를 오프라인/기타로 -/// 나눈다. 순수 함수라 AtchaV2Tests가 직접 검증한다. 현 DEV는 bootstrap이 -/// issuerNotConfigured를 삼켜 재시도 화면에 도달하지 않는다 — 실표출은 S1(익명 인증) 이후. +/// 나눈다. 순수 함수라 AtchaV2Tests가 직접 검증한다. 현재 부트스트랩은 동기 판정 +/// (bootstrapState)이라 실패 경로가 없다 — 서버 검증형 부트스트랩 도입 대비로 유지. enum BootstrapFailureMessage { static let offline = "네트워크 연결을 확인해주세요" static let transient = "일시적인 문제가 생겼어요. 잠시 후 다시 시도해주세요" diff --git a/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift b/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift deleted file mode 100644 index c2f1a8ec..00000000 --- a/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift +++ /dev/null @@ -1,27 +0,0 @@ -public struct TokenPair: Equatable, Sendable { - public let accessToken: String - public let refreshToken: String - - public init(accessToken: String, refreshToken: String) { - self.accessToken = accessToken - self.refreshToken = refreshToken - } -} - -/// Issues a brand-new anonymous session (first launch, or when refresh is no -/// longer possible). The real endpoint spec is pending (미확정 입력 #2); once it -/// lands, add a concrete implementation and swap it in AppDIContainer — no -/// other CoreAuth code changes. -public protocol AnonymousSessionIssuing: Sendable { - func issueSession() async throws -> TokenPair -} - -/// Stand-in until the issuance endpoint spec is confirmed: always throws -/// `AuthError.issuerNotConfigured`. -public struct UnconfiguredAnonymousSessionIssuer: AnonymousSessionIssuing { - public init() {} - - public func issueSession() async throws -> TokenPair { - throw AuthError.issuerNotConfigured - } -} diff --git a/Projects/Core/Auth/Sources/AuthError.swift b/Projects/Core/Auth/Sources/AuthError.swift index 01b67404..7be42e8d 100644 --- a/Projects/Core/Auth/Sources/AuthError.swift +++ b/Projects/Core/Auth/Sources/AuthError.swift @@ -1,8 +1,8 @@ public enum AuthError: Error, Equatable, Sendable { - /// The anonymous-session issuance endpoint spec is not confirmed yet - /// (master prompt, 미확정 입력 #2). `bootstrap()` treats this as non-fatal; - /// `recoverSession()` propagates it. - case issuerNotConfigured + /// The session is definitively gone: no refresh token, or the server + /// rejected the refresh. The user must sign in again — surfaced through + /// `AuthSessionManager.sessionExpired` and thrown to the recovery caller. + case loginRequired /// The reissue envelope came back with a non-success responseCode or an /// empty result. case refreshRejected(responseCode: String) diff --git a/Projects/Core/Auth/Sources/AuthSessionManager.swift b/Projects/Core/Auth/Sources/AuthSessionManager.swift index 966232c9..123863fe 100644 --- a/Projects/Core/Auth/Sources/AuthSessionManager.swift +++ b/Projects/Core/Auth/Sources/AuthSessionManager.swift @@ -1,45 +1,61 @@ import CoreNetwork -/// Owns the anonymous session lifecycle: first-launch issuance, token refresh -/// via GET /auth/reissue, and the 401-recovery chain. Single-flight is -/// guaranteed by keeping the in-progress recovery task on the actor — the -/// legacy interceptor's waiting-queue semantics without the queue. +/// Whether a stored session exists at launch. The splash decides between the +/// home flow (`active`) and the login flow (`loginRequired`) on this alone — +/// token validity is proven lazily by the first authenticated request. +public enum SessionState: Sendable, Equatable { + case active + case loginRequired +} + +/// Owns the session lifecycle: adopting server tokens after social login, +/// token refresh via GET /auth/reissue, and the 401-recovery chain. +/// Single-flight is guaranteed by keeping the in-progress recovery task on +/// the actor — the legacy interceptor's waiting-queue semantics without the +/// queue. public actor AuthSessionManager { /// Read synchronously by the decorator on every request (no actor hop); /// TokenStore is a Sendable value and the keychain store locks internally. public nonisolated let tokenStore: TokenStore + /// Yields once each time the session dies for good (no refresh token, or + /// the server rejected the refresh). Single-consumer stream — the app + /// coordinator subscribes and routes back to login; yields are buffered + /// until consumed. + public nonisolated let sessionExpired: AsyncStream + /// Must be the plain (undecorated) client — the legacy setup used a /// separate interceptor-free session for reissue to break recursion. private let networkClient: any NetworkClient - private let issuer: any AnonymousSessionIssuing + private let expiryContinuation: AsyncStream.Continuation private var recoveryTask: Task? - public init( - tokenStore: TokenStore, - networkClient: any NetworkClient, - issuer: any AnonymousSessionIssuing - ) { + public init(tokenStore: TokenStore, networkClient: any NetworkClient) { self.tokenStore = tokenStore self.networkClient = networkClient - self.issuer = issuer + (sessionExpired, expiryContinuation) = AsyncStream.makeStream(of: Void.self) } - /// Splash-time bootstrap. Issues an anonymous session only when no access - /// token is stored. `issuerNotConfigured` is non-fatal (미확정 입력 #2's - /// interim behavior: proceed without tokens); other failures propagate so - /// the splash can offer retry. - public func bootstrap() async throws { - if (try? tokenStore.accessToken()) != nil { return } - do { - try tokenStore.save(try await issuer.issueSession()) - } catch AuthError.issuerNotConfigured { - return - } + /// Splash-time check: no network, no throw — a keychain read failure just + /// means there is no usable session. + public nonisolated func bootstrapState() -> SessionState { + ((try? tokenStore.accessToken()) != nil) ? .active : .loginRequired + } + + /// Adopts the server token pair obtained by social login. Actor-isolated + /// so adoption serializes with any in-flight recovery. + public func adopt(_ tokens: TokenPair) throws { + try tokenStore.save(tokens) } - /// Single entry point for 401 recovery: refresh first, fall back to a - /// fresh anonymous session, throw when both are impossible. Concurrent + /// Logout/withdrawal path (screens are a later phase — the clear path + /// ships now so callers never reach into TokenStore directly). + public func clearSession() throws { + try tokenStore.clear() + } + + /// Single entry point for 401 recovery: refresh, or declare the session + /// dead (`AuthError.loginRequired` + a `sessionExpired` yield). Concurrent /// callers join the in-flight recovery instead of starting another. public func recoverSession() async throws { if let existing = recoveryTask { @@ -52,18 +68,32 @@ public actor AuthSessionManager { } private func performRecovery() async throws { - if let refreshToken = try? tokenStore.refreshToken() { - do { - return try await refreshTokens(with: refreshToken) - } catch { - // Refresh is dead (rejected, expired, transport) — fall back - // to re-issuing an anonymous session. - } + guard let refreshToken = try? tokenStore.refreshToken() else { + throw expireSession() + } + do { + try await refreshTokens(with: refreshToken) + } catch { + // Only a definitive server rejection kills the session. Transient + // failures (offline, 5xx, decoding) propagate untouched so the + // caller treats them as network errors — never as a logout. + guard isDefinitiveRejection(error) else { throw error } + throw expireSession() } - // Existing tokens are never cleared here: save() overwrites on - // success, and a transient failure must not destroy the anonymous - // identity that owns server-side state. - try tokenStore.save(try await issuer.issueSession()) + } + + /// Best-effort clear (a keychain failure must not mask the expiry), + /// notify the observer, and hand back the error to throw. + private func expireSession() -> AuthError { + try? tokenStore.clear() + expiryContinuation.yield() + return .loginRequired + } + + private func isDefinitiveRejection(_ error: any Error) -> Bool { + if error is AuthError { return true } + if case NetworkError.unacceptableStatus(code: 401, data: _) = error { return true } + return false } private func refreshTokens(with refreshToken: String) async throws { diff --git a/Projects/Core/Auth/Sources/TokenPair.swift b/Projects/Core/Auth/Sources/TokenPair.swift new file mode 100644 index 00000000..6b344c27 --- /dev/null +++ b/Projects/Core/Auth/Sources/TokenPair.swift @@ -0,0 +1,9 @@ +public struct TokenPair: Equatable, Sendable { + public let accessToken: String + public let refreshToken: String + + public init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} diff --git a/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift index 3ec242e1..f59f2095 100644 --- a/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift +++ b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift @@ -8,59 +8,52 @@ struct AuthSessionManagerTests { private var tokenStore: TokenStore { TokenStore(store: backing) } private func makeManager( - handler: @escaping @Sendable (any Endpoint, Int) async throws -> Data = { _, _ in Data() }, - issuer: StubIssuer = StubIssuer(result: .failure(AuthError.issuerNotConfigured)) - ) -> (manager: AuthSessionManager, network: ScriptedNetworkClient, issuer: StubIssuer) { + handler: @escaping @Sendable (any Endpoint, Int) async throws -> Data = { _, _ in Data() } + ) -> (manager: AuthSessionManager, network: ScriptedNetworkClient) { let network = ScriptedNetworkClient(handler: handler) - let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network, issuer: issuer) - return (manager, network, issuer) + let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network) + return (manager, network) } - // MARK: - bootstrap + // MARK: - bootstrapState @Test - func bootstrap_emptyStore_issuesAndSavesTokens() async throws { - let issued = TokenPair(accessToken: "A", refreshToken: "R") - let (manager, _, issuer) = makeManager(issuer: StubIssuer(result: .success(issued))) + func bootstrapState_emptyStore_returnsLoginRequired() { + let (manager, _) = makeManager() - try await manager.bootstrap() - - #expect(issuer.issueCallCount == 1) - #expect(try tokenStore.accessToken() == "A") - #expect(try tokenStore.refreshToken() == "R") + #expect(manager.bootstrapState() == .loginRequired) } @Test - func bootstrap_existingAccessToken_skipsIssuer() async throws { + func bootstrapState_existingAccessToken_returnsActive() throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, _, issuer) = makeManager() + let (manager, _) = makeManager() - try await manager.bootstrap() - - #expect(issuer.issueCallCount == 0) - #expect(try tokenStore.accessToken() == "A") + #expect(manager.bootstrapState() == .active) } - /// 미확정 입력 #2's interim behavior: an unconfigured issuer is non-fatal — - /// the app proceeds without tokens. + // MARK: - adopt / clearSession + @Test - func bootstrap_issuerNotConfigured_completesWithoutTokens() async throws { - let (manager, _, _) = makeManager() + func adopt_savesBothTokens() async throws { + let (manager, _) = makeManager() - try await manager.bootstrap() + try await manager.adopt(TokenPair(accessToken: "A", refreshToken: "R")) - #expect(try tokenStore.accessToken() == nil) + #expect(try tokenStore.accessToken() == "A") + #expect(try tokenStore.refreshToken() == "R") + #expect(manager.bootstrapState() == .active) } @Test - func bootstrap_issuerTransportFailure_throws() async { - let (manager, _, _) = makeManager( - issuer: StubIssuer(result: .failure(URLError(.notConnectedToInternet))) - ) + func clearSession_removesTokens() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _) = makeManager() - await #expect(throws: URLError.self) { - try await manager.bootstrap() - } + try await manager.clearSession() + + #expect(try tokenStore.accessToken() == nil) + #expect(manager.bootstrapState() == .loginRequired) } // MARK: - recoverSession @@ -70,7 +63,7 @@ struct AuthSessionManagerTests { @Test func recoverSession_withRefreshToken_sendsGetReissueWithBearerRefreshHeader() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, network, _) = makeManager(handler: { _, _ in + let (manager, network) = makeManager(handler: { _, _ in reissueSuccessBody(access: "A2", refresh: "R2") }) @@ -85,7 +78,7 @@ struct AuthSessionManagerTests { @Test func recoverSession_reissueSuccess_rotatesBothTokens() async throws { try tokenStore.save(TokenPair(accessToken: "A1", refreshToken: "R1")) - let (manager, _, _) = makeManager(handler: { _, _ in + let (manager, _) = makeManager(handler: { _, _ in reissueSuccessBody(access: "A2", refresh: "R2") }) @@ -95,84 +88,90 @@ struct AuthSessionManagerTests { #expect(try tokenStore.refreshToken() == "R2") } + /// A non-success envelope is a definitive rejection: the session is dead, + /// tokens are cleared, and the caller gets `loginRequired`. @Test - func recoverSession_reissueNonSuccessCode_fallsBackToIssuer() async throws { + func recoverSession_reissueNonSuccessCode_clearsTokensAndThrowsLoginRequired() async throws { try tokenStore.save(TokenPair(accessToken: "A1", refreshToken: "R1")) - let issued = TokenPair(accessToken: "A2", refreshToken: "R2") - let (manager, _, issuer) = makeManager( - handler: { _, _ in reissueRejectedBody(responseCode: "AUTH_401") }, - issuer: StubIssuer(result: .success(issued)) - ) + let (manager, _) = makeManager(handler: { _, _ in + reissueRejectedBody(responseCode: "AUTH_401") + }) - try await manager.recoverSession() + await #expect(throws: AuthError.loginRequired) { + try await manager.recoverSession() + } - #expect(issuer.issueCallCount == 1) - #expect(try tokenStore.accessToken() == "A2") + #expect(try tokenStore.accessToken() == nil) + #expect(try tokenStore.refreshToken() == nil) } @Test - func recoverSession_reissueHTTP401_fallsBackToIssuer() async throws { + func recoverSession_reissueHTTP401_clearsTokensAndThrowsLoginRequired() async throws { try tokenStore.save(TokenPair(accessToken: "A1", refreshToken: "R1")) - let issued = TokenPair(accessToken: "A2", refreshToken: "R2") - let (manager, _, issuer) = makeManager( - handler: { _, _ in throw unauthorizedError() }, - issuer: StubIssuer(result: .success(issued)) - ) + let (manager, _) = makeManager(handler: { _, _ in throw unauthorizedError() }) - try await manager.recoverSession() + await #expect(throws: AuthError.loginRequired) { + try await manager.recoverSession() + } - #expect(issuer.issueCallCount == 1) - #expect(try tokenStore.accessToken() == "A2") + #expect(try tokenStore.accessToken() == nil) } @Test - func recoverSession_noRefreshToken_skipsReissueAndUsesIssuer() async throws { - let issued = TokenPair(accessToken: "A", refreshToken: "R") - let (manager, network, issuer) = makeManager(issuer: StubIssuer(result: .success(issued))) + func recoverSession_noRefreshToken_throwsLoginRequiredWithoutReissue() async throws { + let (manager, network) = makeManager() - try await manager.recoverSession() + await #expect(throws: AuthError.loginRequired) { + try await manager.recoverSession() + } #expect(network.recorded.isEmpty) - #expect(issuer.issueCallCount == 1) - #expect(try tokenStore.accessToken() == "A") } + /// A definitive rejection notifies the expiry stream (buffered — the + /// subscriber may attach later). @Test - func recoverSession_refreshAndIssuerBothFail_throws() async throws { + func recoverSession_definitiveRejection_yieldsSessionExpired() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, _, _) = makeManager( - handler: { _, _ in throw unauthorizedError() }, - issuer: StubIssuer(result: .failure(URLError(.notConnectedToInternet))) - ) + let (manager, _) = makeManager(handler: { _, _ in throw unauthorizedError() }) - await #expect(throws: URLError.self) { + await #expect(throws: AuthError.loginRequired) { try await manager.recoverSession() } + + var iterator = manager.sessionExpired.makeAsyncIterator() + #expect(await iterator.next() != nil) } - /// Unlike bootstrap, recovery must not swallow issuerNotConfigured — the - /// decorator needs the failure to rethrow the original 401. + /// Transient failures (offline etc.) are not a logout: tokens survive and + /// the error propagates untouched so callers treat it as a network error. @Test - func recoverSession_issuerNotConfigured_propagatesError() async { - let (manager, _, _) = makeManager() + func recoverSession_transientFailure_keepsTokensAndPropagates() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _) = makeManager(handler: { _, _ in + throw NetworkError.offline(underlying: URLError(.notConnectedToInternet)) + }) - await #expect(throws: AuthError.issuerNotConfigured) { + await #expect(throws: NetworkError.self) { try await manager.recoverSession() } + + #expect(try tokenStore.accessToken() == "A") + #expect(try tokenStore.refreshToken() == "R") } - /// No destructive clearing: a failed recovery keeps the stored identity. @Test - func recoverSession_reissueFailure_keepsExistingTokensWhenIssuerAlsoFails() async throws { + func recoverSession_reissueHTTP500_keepsTokensAndPropagates() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, _, _) = makeManager(handler: { _, _ in throw unauthorizedError() }) + let (manager, _) = makeManager(handler: { _, _ in + throw NetworkError.unacceptableStatus(code: 500, data: Data()) + }) - await #expect(throws: AuthError.issuerNotConfigured) { + await #expect(throws: NetworkError.self) { try await manager.recoverSession() } #expect(try tokenStore.accessToken() == "A") - #expect(try tokenStore.refreshToken() == "R") } /// Single-flight: while one recovery is parked mid-reissue, concurrent @@ -182,7 +181,7 @@ struct AuthSessionManagerTests { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) let reissueReached = AsyncGate() let reissueRelease = AsyncGate() - let (manager, network, _) = makeManager(handler: { _, _ in + let (manager, network) = makeManager(handler: { _, _ in reissueReached.open() await reissueRelease.wait() return reissueSuccessBody(access: "A2", refresh: "R2") @@ -206,7 +205,7 @@ struct AuthSessionManagerTests { @Test func recoverSession_sequentialCalls_performsReissuePerCall() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, network, _) = makeManager(handler: { _, index in + let (manager, network) = makeManager(handler: { _, index in reissueSuccessBody(access: "A\(index + 2)", refresh: "R\(index + 2)") }) diff --git a/Projects/Core/Auth/Tests/AuthTestStubs.swift b/Projects/Core/Auth/Tests/AuthTestStubs.swift index 8d0ba221..24df87e6 100644 --- a/Projects/Core/Auth/Tests/AuthTestStubs.swift +++ b/Projects/Core/Auth/Tests/AuthTestStubs.swift @@ -59,22 +59,6 @@ final class ScriptedNetworkClient: NetworkClient { } } -final class StubIssuer: AnonymousSessionIssuing { - private let result: Result - private let callCount = Mutex(0) - - init(result: Result) { - self.result = result - } - - var issueCallCount: Int { callCount.withLock { $0 } } - - func issueSession() async throws -> TokenPair { - callCount.withLock { $0 += 1 } - return try result.get() - } -} - /// Deterministic concurrency gate: `wait()` suspends until `open()`; once /// opened, all current and future waiters pass immediately. final class AsyncGate: Sendable { diff --git a/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift b/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift index 87449c99..7f413f09 100644 --- a/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift +++ b/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift @@ -13,23 +13,22 @@ struct AuthenticatedNetworkClientTests { /// and the manager's reissue transport. private func makeSUT( handler: @escaping @Sendable (any Endpoint, Int) async throws -> Data, - issuer: StubIssuer = StubIssuer(result: .failure(AuthError.issuerNotConfigured)), publicPathSuffixes: [String] = ["/auth/reissue"] - ) -> (sut: AuthenticatedNetworkClient, network: ScriptedNetworkClient, issuer: StubIssuer) { + ) -> (sut: AuthenticatedNetworkClient, network: ScriptedNetworkClient) { let network = ScriptedNetworkClient(handler: handler) - let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network, issuer: issuer) + let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network) let sut = AuthenticatedNetworkClient( base: network, sessionManager: manager, publicPathSuffixes: publicPathSuffixes ) - return (sut, network, issuer) + return (sut, network) } @Test func dataFor_withStoredAccessToken_attachesBearerHeader() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (sut, network, _) = makeSUT(handler: { _, _ in Data("ok".utf8) }) + let (sut, network) = makeSUT(handler: { _, _ in Data("ok".utf8) }) let result = try await sut.data(for: TestEndpoint()) @@ -39,7 +38,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_withoutToken_sendsWithoutAuthorizationHeader() async throws { - let (sut, network, _) = makeSUT(handler: { _, _ in Data() }) + let (sut, network) = makeSUT(handler: { _, _ in Data() }) _ = try await sut.data(for: TestEndpoint()) @@ -49,7 +48,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_publicPathSuffix_skipsAuthorizationHeader() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (sut, network, _) = makeSUT( + let (sut, network) = makeSUT( handler: { _, _ in Data() }, publicPathSuffixes: ["/public/thing"] ) @@ -62,7 +61,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_on401_refreshesAndRetriesWithNewToken() async throws { try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) - let (sut, network, _) = makeSUT(handler: { endpoint, index in + let (sut, network) = makeSUT(handler: { endpoint, index in switch (endpoint.path, index) { case ("/things", 0): throw unauthorizedError() case ("/things", _): return Data("ok".utf8) @@ -82,7 +81,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_on401RetryAlso401_throwsWithoutSecondRefresh() async throws { try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) - let (sut, network, _) = makeSUT(handler: { endpoint, _ in + let (sut, network) = makeSUT(handler: { endpoint, _ in if endpoint.path == "/things" { throw unauthorizedError() } return reissueSuccessBody(access: "NEW", refresh: "R2") }) @@ -100,7 +99,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_on401RecoveryFails_rethrowsOriginalUnauthorized() async throws { let marker = Data("original-401".utf8) - let (sut, network, _) = makeSUT(handler: { _, _ in + let (sut, network) = makeSUT(handler: { _, _ in throw NetworkError.unacceptableStatus(code: 401, data: marker) }) @@ -118,32 +117,30 @@ struct AuthenticatedNetworkClientTests { #expect(network.recordedCalls(to: "/things").count == 1) } + /// A definitively rejected refresh kills the session: the decorator + /// rethrows the original 401 without an unauthenticated retry, and the + /// dead tokens are cleared. @Test - func dataFor_401RefreshFailsIssuerSucceeds_retriesWithIssuedToken() async throws { + func dataFor_401RefreshRejected_rethrowsOriginal401AndClearsTokens() async throws { try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "DEAD")) - let issued = TokenPair(accessToken: "ISSUED", refreshToken: "R2") - let (sut, network, issuer) = makeSUT( - handler: { endpoint, index in - switch (endpoint.path, index) { - case ("/things", 0): throw unauthorizedError() - case ("/things", _): return Data("ok".utf8) - default: throw unauthorizedError() // reissue rejected — refresh is dead - } - }, - issuer: StubIssuer(result: .success(issued)) - ) + let (sut, network) = makeSUT(handler: { endpoint, _ in + if endpoint.path == "/things" { throw unauthorizedError() } + return reissueRejectedBody(responseCode: "AUTH_401") + }) - let result = try await sut.data(for: TestEndpoint()) + await #expect(throws: NetworkError.self) { + try await sut.data(for: TestEndpoint()) + } - #expect(result == Data("ok".utf8)) - #expect(issuer.issueCallCount == 1) - #expect(network.recordedCalls(to: "/things").last?.headers["Authorization"] == "Bearer ISSUED") + #expect(network.recordedCalls(to: "/things").count == 1) + #expect(network.recordedCalls(to: "/auth/reissue").count == 1) + #expect(try tokenStore.accessToken() == nil) } @Test func dataFor_non401Status_propagatesWithoutRecovery() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (sut, network, _) = makeSUT(handler: { _, _ in + let (sut, network) = makeSUT(handler: { _, _ in throw NetworkError.unacceptableStatus(code: 500, data: Data()) }) @@ -161,7 +158,7 @@ struct AuthenticatedNetworkClientTests { let value: String } try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) - let (sut, network, _) = makeSUT(handler: { endpoint, index in + let (sut, network) = makeSUT(handler: { endpoint, index in switch (endpoint.path, index) { case ("/things", 0): throw unauthorizedError() case ("/things", _): return Data(#"{"value": "hi"}"#.utf8) @@ -178,7 +175,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_endpointWithCustomHeaders_preservesThemAndAddsBearer() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (sut, network, _) = makeSUT(handler: { _, _ in Data() }) + let (sut, network) = makeSUT(handler: { _, _ in Data() }) _ = try await sut.data(for: TestEndpoint(headers: ["X-Custom": "1"])) @@ -194,7 +191,7 @@ struct AuthenticatedNetworkClientTests { try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) let reissueReached = AsyncGate() let reissueRelease = AsyncGate() - let (sut, network, _) = makeSUT(handler: { endpoint, _ in + let (sut, network) = makeSUT(handler: { endpoint, _ in if endpoint.path == "/auth/reissue" { reissueReached.open() await reissueRelease.wait() From 7cd1afdef2675126e9b1b1bbe00198c31d40089c Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 13 Sep 2026 13:04:24 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20Domain=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=88=98=EC=A7=81=20=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=8A=A4=20=E2=80=94=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=C2=B7=ED=8F=AC=ED=8A=B8=C2=B7SignInUseCase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SocialCredential/SocialLoginProvider/SocialLoginError, LoginSession 엔티티 - 포트 4종: SocialLoginService(@MainActor authorize), AuthRepository(check/login), SessionStoring, PushTokenProviding - DefaultSignInUseCase: authorize → check → (기존 회원) login → 세션 채택 / 미가입은 needsSignUp (소셜 자격 증명 미보관) - DefaultSignInUseCaseTests 5케이스(호출 순서·미가입 단락·취소 전파·store 실패 전파·fcm nil) Co-Authored-By: Claude Fable 5 --- .../Sources/Entities/LoginSession.swift | 13 ++ .../Sources/Entities/SocialCredential.swift | 26 ++++ .../Sources/Interfaces/AuthRepository.swift | 9 ++ .../Interfaces/PushTokenProviding.swift | 5 + .../Sources/Interfaces/SessionStoring.swift | 5 + .../Interfaces/SocialLoginService.swift | 5 + .../Sources/UseCases/SignInUseCase.swift | 50 +++++++ .../Tests/DefaultSignInUseCaseTests.swift | 134 ++++++++++++++++++ 8 files changed, 247 insertions(+) create mode 100644 Projects/Domain/Sources/Entities/LoginSession.swift create mode 100644 Projects/Domain/Sources/Entities/SocialCredential.swift create mode 100644 Projects/Domain/Sources/Interfaces/AuthRepository.swift create mode 100644 Projects/Domain/Sources/Interfaces/PushTokenProviding.swift create mode 100644 Projects/Domain/Sources/Interfaces/SessionStoring.swift create mode 100644 Projects/Domain/Sources/Interfaces/SocialLoginService.swift create mode 100644 Projects/Domain/Sources/UseCases/SignInUseCase.swift create mode 100644 Projects/Domain/Tests/DefaultSignInUseCaseTests.swift diff --git a/Projects/Domain/Sources/Entities/LoginSession.swift b/Projects/Domain/Sources/Entities/LoginSession.swift new file mode 100644 index 00000000..795a1433 --- /dev/null +++ b/Projects/Domain/Sources/Entities/LoginSession.swift @@ -0,0 +1,13 @@ +/// `/auth/login` 성공 결과 — 서버가 교환해준 토큰 쌍. +/// 응답의 집 좌표(lat/lon)는 현 스코프에서 쓰지 않으므로 엔티티에서 생략한다(DTO만 디코딩). +public struct LoginSession: Sendable, Equatable { + public let userID: Int? + public let accessToken: String + public let refreshToken: String + + public init(userID: Int?, accessToken: String, refreshToken: String) { + self.userID = userID + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} diff --git a/Projects/Domain/Sources/Entities/SocialCredential.swift b/Projects/Domain/Sources/Entities/SocialCredential.swift new file mode 100644 index 00000000..cf7ccd9c --- /dev/null +++ b/Projects/Domain/Sources/Entities/SocialCredential.swift @@ -0,0 +1,26 @@ +/// 소셜 로그인 제공자 — 서버 provider 파라미터 값(kakao=0, apple=1)은 Data 레이어가 매핑한다. +public enum SocialLoginProvider: Sendable, Equatable, CaseIterable { + case kakao + case apple +} + +/// 소셜 SDK가 발급한 자격 증명 — 카카오는 OAuth access token, 애플은 identityToken. +/// 서버 `/auth/*` 호출에서 Bearer로 실려 서버 토큰과 교환된다. +public struct SocialCredential: Sendable, Equatable { + public let provider: SocialLoginProvider + public let accessToken: String + + public init(provider: SocialLoginProvider, accessToken: String) { + self.provider = provider + self.accessToken = accessToken + } +} + +public enum SocialLoginError: Error, Equatable, Sendable { + /// 사용자가 소셜 로그인 UI를 스스로 닫음 — 에러 표출 없이 idle 복귀. + case cancelled + /// 제공자를 쓸 수 없는 상태(카카오 SDK 미초기화 등). + case providerUnavailable + /// 그 외 제공자 실패. + case failed +} diff --git a/Projects/Domain/Sources/Interfaces/AuthRepository.swift b/Projects/Domain/Sources/Interfaces/AuthRepository.swift new file mode 100644 index 00000000..21d42f6e --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/AuthRepository.swift @@ -0,0 +1,9 @@ +/// 로그인 도메인 API(`/auth/check`·`/auth/login`) — 구현은 AtchaData. +/// 소셜 자격 증명을 Bearer로 보내는 특수 계약이라 서버 토큰 데코레이터를 타지 않는다 +/// (조합 루트가 plain client를 주입). +public protocol AuthRepository: Sendable { + /// GET /auth/check — 이 소셜 계정이 이미 가입돼 있는가. + func checkRegistration(credential: SocialCredential) async throws -> Bool + /// GET /auth/login — 소셜 자격 증명을 서버 토큰 쌍으로 교환. + func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession +} diff --git a/Projects/Domain/Sources/Interfaces/PushTokenProviding.swift b/Projects/Domain/Sources/Interfaces/PushTokenProviding.swift new file mode 100644 index 00000000..30c4bf77 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/PushTokenProviding.swift @@ -0,0 +1,5 @@ +/// 푸시 토큰 포트 — `/auth/login`의 fcmToken 파라미터 소싱용. +/// V2는 FCM 토큰 서버 전달이 미정(미확정 #5)이라 nil이 일반 상태다. +public protocol PushTokenProviding: Sendable { + func currentPushToken() async -> String? +} diff --git a/Projects/Domain/Sources/Interfaces/SessionStoring.swift b/Projects/Domain/Sources/Interfaces/SessionStoring.swift new file mode 100644 index 00000000..0fec2ca2 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/SessionStoring.swift @@ -0,0 +1,5 @@ +/// 서버 토큰 채택 포트 — Domain이 CoreAuth를 모른 채 로그인 결과를 세션에 반영한다. +/// 어댑터는 App에서 `AuthSessionManager.adopt(_:)`로 배선. +public protocol SessionStoring: Sendable { + func store(accessToken: String, refreshToken: String) async throws +} diff --git a/Projects/Domain/Sources/Interfaces/SocialLoginService.swift b/Projects/Domain/Sources/Interfaces/SocialLoginService.swift new file mode 100644 index 00000000..1d9bed18 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/SocialLoginService.swift @@ -0,0 +1,5 @@ +/// 소셜 SDK 포트 — 어댑터 구현은 App에 둔다(외부 SDK는 앱 타겟에서만 링크). +/// 제공자 UI(카카오톡 전환·Apple 시트)를 띄우므로 메인 액터 격리. +public protocol SocialLoginService: Sendable { + @MainActor func authorize(provider: SocialLoginProvider) async throws -> SocialCredential +} diff --git a/Projects/Domain/Sources/UseCases/SignInUseCase.swift b/Projects/Domain/Sources/UseCases/SignInUseCase.swift new file mode 100644 index 00000000..dd11ab2e --- /dev/null +++ b/Projects/Domain/Sources/UseCases/SignInUseCase.swift @@ -0,0 +1,50 @@ +public enum SignInOutcome: Sendable, Equatable { + /// 서버 토큰 채택까지 완료 — 홈 진입 가능. + case success + /// 미가입 소셜 계정 — 가입 플로우는 후속 Phase, 화면은 안내만 띄운다. + case needsSignUp +} + +/// 소셜 로그인 전 과정: 소셜 인가 → 가입 여부 확인 → (기존 회원이면) 토큰 교환·세션 채택. +/// 소셜 UI를 띄우는 authorize가 메인 액터 격리라 execute도 @MainActor. +public protocol SignInUseCase: Sendable { + @MainActor func execute(provider: SocialLoginProvider) async throws -> SignInOutcome +} + +public final class DefaultSignInUseCase: SignInUseCase { + private let socialLoginService: any SocialLoginService + private let authRepository: any AuthRepository + private let sessionStore: any SessionStoring + private let pushTokenProvider: any PushTokenProviding + + public init( + socialLoginService: any SocialLoginService, + authRepository: any AuthRepository, + sessionStore: any SessionStoring, + pushTokenProvider: any PushTokenProviding + ) { + self.socialLoginService = socialLoginService + self.authRepository = authRepository + self.sessionStore = sessionStore + self.pushTokenProvider = pushTokenProvider + } + + @MainActor + public func execute(provider: SocialLoginProvider) async throws -> SignInOutcome { + let credential = try await socialLoginService.authorize(provider: provider) + guard try await authRepository.checkRegistration(credential: credential) else { + // 미가입 시 소셜 자격 증명을 보관하지 않는다 — 가입 플로우가 없는 현 + // 스코프에서 평문 보관은 위험만 남긴다(레거시의 UserDefaults 보관 미계승). + return .needsSignUp + } + let session = try await authRepository.login( + credential: credential, + fcmToken: await pushTokenProvider.currentPushToken() + ) + try await sessionStore.store( + accessToken: session.accessToken, + refreshToken: session.refreshToken + ) + return .success + } +} diff --git a/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift b/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift new file mode 100644 index 00000000..60ac3235 --- /dev/null +++ b/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift @@ -0,0 +1,134 @@ +@testable import Domain +import Foundation +import Synchronization +import Testing + +/// 스텁 공유 기록기 — 호출 순서·횟수·전달 인자를 한 곳에서 검증한다. +private final class SignInRecorder: Sendable { + private let events = Mutex<[String]>([]) + + func record(_ event: String) { events.withLock { $0.append(event) } } + var log: [String] { events.withLock { $0 } } + func count(of event: String) -> Int { log.count(where: { $0 == event }) } +} + +private struct StubSocialLoginService: SocialLoginService { + let recorder: SignInRecorder + var result: Result = + .success(SocialCredential(provider: .kakao, accessToken: "SOCIAL")) + + func authorize(provider: SocialLoginProvider) async throws -> SocialCredential { + recorder.record("authorize") + return try result.get() + } +} + +private struct StubAuthRepository: AuthRepository { + let recorder: SignInRecorder + var exists = true + var session = LoginSession(userID: 1, accessToken: "SA", refreshToken: "SR") + + func checkRegistration(credential: SocialCredential) async throws -> Bool { + recorder.record("check(\(credential.accessToken))") + return exists + } + + func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession { + recorder.record("login(fcm:\(fcmToken ?? "nil"))") + return session + } +} + +private struct StubSessionStore: SessionStoring { + let recorder: SignInRecorder + var error: (any Error)? + + func store(accessToken: String, refreshToken: String) async throws { + recorder.record("store(\(accessToken)/\(refreshToken))") + if let error { throw error } + } +} + +private struct StubPushTokenProvider: PushTokenProviding { + var token: String? + + func currentPushToken() async -> String? { token } +} + +private struct StoreError: Error {} + +struct DefaultSignInUseCaseTests { + private let recorder = SignInRecorder() + + private func makeSUT( + social: StubSocialLoginService? = nil, + repository: StubAuthRepository? = nil, + store: StubSessionStore? = nil, + pushToken: String? = nil + ) -> DefaultSignInUseCase { + DefaultSignInUseCase( + socialLoginService: social ?? StubSocialLoginService(recorder: recorder), + authRepository: repository ?? StubAuthRepository(recorder: recorder), + sessionStore: store ?? StubSessionStore(recorder: recorder), + pushTokenProvider: StubPushTokenProvider(token: pushToken) + ) + } + + @Test + func execute_registered_runsAuthorizeCheckLoginStoreInOrder() async throws { + let sut = makeSUT(pushToken: "FCM") + + let outcome = try await sut.execute(provider: .kakao) + + #expect(outcome == .success) + #expect(recorder.log == [ + "authorize", + "check(SOCIAL)", + "login(fcm:FCM)", + "store(SA/SR)", + ]) + } + + @Test + func execute_noPushToken_passesNilToLogin() async throws { + let sut = makeSUT() + + _ = try await sut.execute(provider: .apple) + + #expect(recorder.count(of: "login(fcm:nil)") == 1) + } + + @Test + func execute_notRegistered_returnsNeedsSignUpWithoutLoginOrStore() async throws { + let sut = makeSUT(repository: StubAuthRepository(recorder: recorder, exists: false)) + + let outcome = try await sut.execute(provider: .kakao) + + #expect(outcome == .needsSignUp) + #expect(recorder.log == ["authorize", "check(SOCIAL)"]) + } + + @Test + func execute_authorizeCancelled_propagatesWithoutServerCalls() async { + let sut = makeSUT( + social: StubSocialLoginService(recorder: recorder, result: .failure(.cancelled)) + ) + + await #expect(throws: SocialLoginError.cancelled) { + try await sut.execute(provider: .kakao) + } + + #expect(recorder.log == ["authorize"]) + } + + @Test + func execute_storeFailure_propagates() async { + let sut = makeSUT(store: StubSessionStore(recorder: recorder, error: StoreError())) + + await #expect(throws: StoreError.self) { + try await sut.execute(provider: .kakao) + } + + #expect(recorder.count(of: "store(SA/SR)") == 1) + } +} From 106df6c0352f5c0d5186d7503d5ce99a1856e4ad Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 13 Sep 2026 13:06:16 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20AtchaData=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20API=20=E2=80=94=20AuthEndpoint=C2=B7DTO=C2=B7AuthRe?= =?UTF-8?q?positoryImpl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthEndpoint: GET /auth/check·/auth/login, 소셜 자격 증명 Bearer + provider(kakao=0/apple=1), fcmToken nil→"" (레거시 실측 계약) - LoginResponseDTO(전 필드 옵셔널, lat/lon 디코딩만) + toEntity(토큰 부재 throw) - AuthRepositoryImpl: plain client 주입 규약(소셜 Bearer 보존·401 복구 미촉발) - 계약 핀 테스트 8케이스 Co-Authored-By: Claude Fable 5 --- .../Data/Sources/DTO/AuthResponseDTO.swift | 23 ++++++++ .../Data/Sources/Network/AuthEndpoint.swift | 54 +++++++++++++++++++ .../Repositories/AuthRepositoryImpl.swift | 26 +++++++++ Projects/Data/Tests/AuthEndpointTests.swift | 53 ++++++++++++++++++ .../Data/Tests/AuthResponseDTOTests.swift | 47 ++++++++++++++++ 5 files changed, 203 insertions(+) create mode 100644 Projects/Data/Sources/DTO/AuthResponseDTO.swift create mode 100644 Projects/Data/Sources/Network/AuthEndpoint.swift create mode 100644 Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift create mode 100644 Projects/Data/Tests/AuthEndpointTests.swift create mode 100644 Projects/Data/Tests/AuthResponseDTOTests.swift diff --git a/Projects/Data/Sources/DTO/AuthResponseDTO.swift b/Projects/Data/Sources/DTO/AuthResponseDTO.swift new file mode 100644 index 00000000..0eaeed0b --- /dev/null +++ b/Projects/Data/Sources/DTO/AuthResponseDTO.swift @@ -0,0 +1,23 @@ +import CoreNetwork +import Domain + +struct AuthCheckResponseDTO: Decodable, Sendable { + let exists: Bool +} + +/// `/auth/login` 응답(레거시 LoginDTO 실측) — 전 필드 옵셔널, 좌표 키는 "lat"/"lon". +/// lat/lon(집 좌표)은 현 스코프 미사용이라 디코딩만 하고 엔티티로 올리지 않는다. +struct LoginResponseDTO: Decodable, Sendable { + let id: Int? + let accessToken: String? + let refreshToken: String? + let lat: Double? + let lon: Double? + + func toEntity() throws -> LoginSession { + guard let accessToken, let refreshToken else { + throw NetworkError.decoding(underlying: MissingResultError()) + } + return LoginSession(userID: id, accessToken: accessToken, refreshToken: refreshToken) + } +} diff --git a/Projects/Data/Sources/Network/AuthEndpoint.swift b/Projects/Data/Sources/Network/AuthEndpoint.swift new file mode 100644 index 00000000..b848447f --- /dev/null +++ b/Projects/Data/Sources/Network/AuthEndpoint.swift @@ -0,0 +1,54 @@ +import CoreNetwork +import Domain +import Foundation + +/// `/auth/check`·`/auth/login` — 소셜 자격 증명을 Bearer로 보내는 특수 계약(레거시 실측). +/// AuthenticatedNetworkClient를 타면 Authorization이 서버 토큰으로 덮이므로, +/// 이 엔드포인트는 반드시 plain client로 호출한다(AuthRepositoryImpl 주입 규약). +enum AuthEndpoint: Endpoint { + case check(SocialCredential) + case login(SocialCredential, fcmToken: String?) + + var path: String { + switch self { + case .check: "/auth/check" + case .login: "/auth/login" + } + } + + var method: HTTPMethod { + switch self { + case .check, .login: .get + } + } + + var headers: [String: String] { + switch self { + case let .check(credential), let .login(credential, _): + ["Authorization": "Bearer \(credential.accessToken)"] + } + } + + var queryItems: [URLQueryItem] { + switch self { + case let .check(credential): + [URLQueryItem(name: "provider", value: String(credential.provider.serverCode))] + case let .login(credential, fcmToken): + [ + URLQueryItem(name: "provider", value: String(credential.provider.serverCode)), + // 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. + URLQueryItem(name: "fcmToken", value: fcmToken ?? ""), + ] + } + } +} + +extension SocialLoginProvider { + /// 서버 provider 코드 — 레거시 `LoginType` raw value 실측(kakao=0, apple=1). + var serverCode: Int { + switch self { + case .kakao: 0 + case .apple: 1 + } + } +} diff --git a/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift new file mode 100644 index 00000000..c194bbba --- /dev/null +++ b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift @@ -0,0 +1,26 @@ +import CoreNetwork +import Domain + +public struct AuthRepositoryImpl: AuthRepository { + private let networkClient: any NetworkClient + + /// plain client(AuthenticatedNetworkClient 미적용)를 주입할 것 — Authorization에 + /// 소셜 자격 증명을 실어야 하고, 401이 세션 복구를 촉발해서도 안 된다. + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + public func checkRegistration(credential: SocialCredential) async throws -> Bool { + let dto: AuthCheckResponseDTO = try await networkClient.requestEnveloped( + AuthEndpoint.check(credential) + ) + return dto.exists + } + + public func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession { + let dto: LoginResponseDTO = try await networkClient.requestEnveloped( + AuthEndpoint.login(credential, fcmToken: fcmToken) + ) + return try dto.toEntity() + } +} diff --git a/Projects/Data/Tests/AuthEndpointTests.swift b/Projects/Data/Tests/AuthEndpointTests.swift new file mode 100644 index 00000000..540f4a54 --- /dev/null +++ b/Projects/Data/Tests/AuthEndpointTests.swift @@ -0,0 +1,53 @@ +@testable import AtchaData +import CoreNetwork +import Domain +import Foundation +import Testing + +struct AuthEndpointTests { + private let kakaoCredential = SocialCredential(provider: .kakao, accessToken: "KAKAO_AT") + private let appleCredential = SocialCredential(provider: .apple, accessToken: "APPLE_IDT") + + /// 레거시 실측 계약 핀: 소셜 토큰이 Bearer로, provider는 kakao=0. + @Test + func check_kakao_sendsSocialBearerAndProviderZero() { + let endpoint = AuthEndpoint.check(kakaoCredential) + + #expect(endpoint.path == "/auth/check") + #expect(endpoint.method == .get) + #expect(endpoint.headers == ["Authorization": "Bearer KAKAO_AT"]) + #expect(endpoint.queryItems == [URLQueryItem(name: "provider", value: "0")]) + } + + @Test + func check_apple_sendsProviderOne() { + let endpoint = AuthEndpoint.check(appleCredential) + + #expect(endpoint.headers == ["Authorization": "Bearer APPLE_IDT"]) + #expect(endpoint.queryItems == [URLQueryItem(name: "provider", value: "1")]) + } + + @Test + func login_sendsProviderAndFcmToken() { + let endpoint = AuthEndpoint.login(kakaoCredential, fcmToken: "FCM") + + #expect(endpoint.path == "/auth/login") + #expect(endpoint.method == .get) + #expect(endpoint.headers == ["Authorization": "Bearer KAKAO_AT"]) + #expect(endpoint.queryItems == [ + URLQueryItem(name: "provider", value: "0"), + URLQueryItem(name: "fcmToken", value: "FCM"), + ]) + } + + /// 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. + @Test + func login_withoutFcmToken_sendsEmptyStringLikeLegacy() { + let endpoint = AuthEndpoint.login(appleCredential, fcmToken: nil) + + #expect(endpoint.queryItems == [ + URLQueryItem(name: "provider", value: "1"), + URLQueryItem(name: "fcmToken", value: ""), + ]) + } +} diff --git a/Projects/Data/Tests/AuthResponseDTOTests.swift b/Projects/Data/Tests/AuthResponseDTOTests.swift new file mode 100644 index 00000000..99aea7f7 --- /dev/null +++ b/Projects/Data/Tests/AuthResponseDTOTests.swift @@ -0,0 +1,47 @@ +@testable import AtchaData +import CoreNetwork +import Domain +import Foundation +import Testing + +struct AuthResponseDTOTests { + @Test + func loginResponse_decodesLegacyKeysAndMapsToEntity() throws { + let json = Data(""" + {"id": 7, "accessToken": "SA", "refreshToken": "SR", "lat": 37.5, "lon": 127.0} + """.utf8) + + let dto = try JSONDecoder().decode(LoginResponseDTO.self, from: json) + let session = try dto.toEntity() + + #expect(session == LoginSession(userID: 7, accessToken: "SA", refreshToken: "SR")) + } + + @Test + func loginResponse_missingTokens_toEntityThrows() throws { + let json = Data(#"{"id": 7}"#.utf8) + let dto = try JSONDecoder().decode(LoginResponseDTO.self, from: json) + + #expect(throws: NetworkError.self) { + _ = try dto.toEntity() + } + } + + @Test + func loginResponse_missingUserID_stillMapsWithNilID() throws { + let json = Data(#"{"accessToken": "SA", "refreshToken": "SR"}"#.utf8) + let dto = try JSONDecoder().decode(LoginResponseDTO.self, from: json) + + #expect(try dto.toEntity().userID == nil) + } + + @Test + func checkResponse_decodesExists() throws { + let dto = try JSONDecoder().decode( + AuthCheckResponseDTO.self, + from: Data(#"{"exists": true}"#.utf8) + ) + + #expect(dto.exists) + } +} From 15b3efb9e13dfe920b07ddea4e7460c57eefd83d Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 13 Sep 2026 13:13:30 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20AuthFeature=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EC=8B=A0=EC=84=A4=20=E2=80=94=20=EB=A0=88=EA=B1=B0=EC=8B=9C?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=94=ED=85=80=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20V2=20=EC=9D=B4=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Project.feature(name: "Auth") 4타겟 + Workspace 등록, 에셋(kakao/apple imageset) 레거시에서 복사 - LoginViewController: 레거시 시트 비주얼(dim 0.9·컨테이너 198·핸들·슬라이드업) 이식, 강제 로그인이라 dismiss 제스처 제거 - LoginViewModel: State(idle/loading) + 원샷 Event(미가입 안내/실패 토스트), 취소는 무표출 idle 복귀, 로딩 중 중복 탭 가드 - SocialLoginButton(레거시 스펙 52·radius8·아이콘 leading16) + AuthPalette(카카오 브랜드 상수 — DSPalette 미오염) - LoginCoordinator(overFullScreen present, 성공 시 하강 연출 후 onAuthenticated→finish) + AuthDIContainer - #if DEV 건너뛰기 버튼(시뮬 자동 검수용, DevDemoFallbacks와 함께 제거 예정) - LoginViewModelTests 6케이스 + Example(미가입→실패→성공 순환 스텁) Co-Authored-By: Claude Fable 5 --- .../Feature/Auth/Example/ExampleApp.swift | 109 +++++++++++ .../Interface/AuthCoordinatorBuildable.swift | 15 ++ Projects/Feature/Auth/Project.swift | 23 +++ .../AuthFeature.xcassets/Contents.json | 6 + .../apple.imageset/Button Icon.png | Bin 0 -> 348 bytes .../apple.imageset/Button Icon@2x.png | Bin 0 -> 624 bytes .../apple.imageset/Button Icon@3x.png | Bin 0 -> 933 bytes .../apple.imageset/Contents.json | 26 +++ .../kakao.imageset/Button Icon.png | Bin 0 -> 454 bytes .../kakao.imageset/Button Icon@2x.png | Bin 0 -> 772 bytes .../kakao.imageset/Button Icon@3x.png | Bin 0 -> 1106 bytes .../kakao.imageset/Contents.json | 26 +++ .../Auth/Sources/AuthDIContainer.swift | 31 ++++ .../Feature/Auth/Sources/AuthPalette.swift | 10 + .../Auth/Sources/LoginCoordinator.swift | 46 +++++ .../Auth/Sources/LoginViewController.swift | 172 ++++++++++++++++++ .../Feature/Auth/Sources/LoginViewModel.swift | 86 +++++++++ .../Auth/Sources/SocialLoginButton.swift | 53 ++++++ .../Auth/Tests/LoginViewModelTests.swift | 147 +++++++++++++++ Workspace.swift | 1 + 20 files changed, 751 insertions(+) create mode 100644 Projects/Feature/Auth/Example/ExampleApp.swift create mode 100644 Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift create mode 100644 Projects/Feature/Auth/Project.swift create mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json create mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png create mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png create mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@3x.png create mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Contents.json create mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon.png create mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@2x.png create mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@3x.png create mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Contents.json create mode 100644 Projects/Feature/Auth/Sources/AuthDIContainer.swift create mode 100644 Projects/Feature/Auth/Sources/AuthPalette.swift create mode 100644 Projects/Feature/Auth/Sources/LoginCoordinator.swift create mode 100644 Projects/Feature/Auth/Sources/LoginViewController.swift create mode 100644 Projects/Feature/Auth/Sources/LoginViewModel.swift create mode 100644 Projects/Feature/Auth/Sources/SocialLoginButton.swift create mode 100644 Projects/Feature/Auth/Tests/LoginViewModelTests.swift diff --git a/Projects/Feature/Auth/Example/ExampleApp.swift b/Projects/Feature/Auth/Example/ExampleApp.swift new file mode 100644 index 00000000..5346ddc3 --- /dev/null +++ b/Projects/Feature/Auth/Example/ExampleApp.swift @@ -0,0 +1,109 @@ +import AuthFeature +import AuthFeatureInterface +import CoreCoordinator +import Domain +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role) + } +} + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + private let navigationController = UINavigationController() + private var loginCoordinator: (any Coordinator)? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + + let launcher = LauncherViewController() + launcher.onStart = { [weak self] in self?.startLoginFlow() } + navigationController.viewControllers = [launcher] + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = navigationController + window.makeKeyAndVisible() + self.window = window + } + + private func startLoginFlow() { + let container = AuthDIContainer(signInUseCase: PreviewSignInUseCase()) + let coordinator = container.makeLoginCoordinator( + navigationController: navigationController, + onAuthenticated: { [weak self] in self?.showAuthenticated() } + ) + coordinator.finishDelegate = self + loginCoordinator = coordinator + coordinator.start() + } + + private func showAuthenticated() { + let alert = UIAlertController(title: "로그인 완료", message: "세션 채택 성공 경로", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + navigationController.present(alert, animated: true) + } +} + +extension SceneDelegate: CoordinatorFinishDelegate { + // finish 후 런처로 복귀 — 플로우를 반복 시연할 수 있다. + func coordinatorDidFinish(_ coordinator: any Coordinator) { + loginCoordinator = nil + } +} + +private final class LauncherViewController: UIViewController { + var onStart: (() -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + title = "AuthFeature Example" + + let startButton = UIButton(type: .system) + startButton.setTitle("로그인 시트 열기 (미가입→실패→성공 순환)", for: .normal) + startButton.addAction(UIAction { [weak self] _ in self?.onStart?() }, for: .touchUpInside) + startButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(startButton) + NSLayoutConstraint.activate([ + startButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + startButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } +} + +/// 탭할 때마다 미가입 안내 → 실패 토스트 → 성공을 순환 — 세 UX를 모두 시연한다. +/// (스텁이 Tests와 중복되는 것은 의도된 트레이드오프 — 규약 참고.) +@MainActor +private final class PreviewSignInUseCase: SignInUseCase { + private var attempt = 0 + + private struct PreviewError: Error {} + + func execute(provider: SocialLoginProvider) async throws -> SignInOutcome { + try? await Task.sleep(for: .seconds(1)) + defer { attempt += 1 } + switch attempt % 3 { + case 0: return .needsSignUp + case 1: throw PreviewError() + default: return .success + } + } +} diff --git a/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift b/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift new file mode 100644 index 00000000..8f7482b5 --- /dev/null +++ b/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift @@ -0,0 +1,15 @@ +import CoreCoordinator +import UIKit + +/// Entry point other modules use to start the login flow. +/// The concrete builder is the feature's DIContainer; wiring happens at the +/// App composition root. +@MainActor +public protocol AuthCoordinatorBuildable { + /// 강제 로그인 플로우 — 성공(세션 채택 완료) 시에만 onAuthenticated가 불린다. + /// 취소 경로는 없다(시트 dismiss 제스처 미제공). + func makeLoginCoordinator( + navigationController: UINavigationController, + onAuthenticated: @escaping () -> Void + ) -> any Coordinator +} diff --git a/Projects/Feature/Auth/Project.swift b/Projects/Feature/Auth/Project.swift new file mode 100644 index 00000000..453a3675 --- /dev/null +++ b/Projects/Feature/Auth/Project.swift @@ -0,0 +1,23 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.feature( + name: "Auth", + dependencies: [ + .project(target: "Domain", path: "../../Domain"), + .project(target: "DesignSystem", path: "../../DesignSystem"), + .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), + .external(name: "SnapKit"), + ], + interfaceDependencies: [ + .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), + ], + testDependencies: [ + .project(target: "Domain", path: "../../Domain"), + ], + exampleDependencies: [ + .project(target: "Domain", path: "../../Domain"), + .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), + ], + resources: ["Resources/**"] +) diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1619631eafbdbe7ae72ea2adf8cfadff42dadeef GIT binary patch literal 348 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O)#kDe}$Ar*{Qr#=*HGT>dyNwyWHXG!deAG)k#(hF+Zt>!t>>&47p8s+m#qiGzEoz@5UfgSB+E_B_ z_tV`cK3YEG-*PcSDCUBjimb_1{tV@eRqHPtJO6jXZ>j1HA6ZhH9!>Eo08)E+jSrs< zT&-kYa&f`iCE9t5y;L`B)7qSM{i@RCgI5oh%!u5vdeMi^%ahjGO;t&mQEVtzG*Kn+ q%8BV4E9T~X+H>@Z;`i%&7&aU`bBW~?`*Wb@89ZJ6T-G@yGywoq>5Rz$ literal 0 HcmV?d00001 diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..230f9e87d0a0fd7c7b98c224c2eabbf4c90cae73 GIT binary patch literal 624 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 Void + ) -> any Coordinator { + LoginCoordinator( + navigationController: navigationController, + container: self, + onAuthenticated: onAuthenticated + ) + } + + func makeLoginViewController(onAuthenticated: @escaping () -> Void) -> LoginViewController { + let viewModel = LoginViewModel(signInUseCase: signInUseCase) + viewModel.onAuthenticated = onAuthenticated + return LoginViewController(viewModel: viewModel) + } +} diff --git a/Projects/Feature/Auth/Sources/AuthPalette.swift b/Projects/Feature/Auth/Sources/AuthPalette.swift new file mode 100644 index 00000000..77388f37 --- /dev/null +++ b/Projects/Feature/Auth/Sources/AuthPalette.swift @@ -0,0 +1,10 @@ +import UIKit + +/// 소셜 브랜드 고정색 — 테마를 타는 시맨틱 토큰이 아니라 브랜드 상수라 DSPalette에 +/// 올리지 않는다(소비처가 로그인 화면뿐). 값은 레거시 colorset 실측. +enum AuthPalette { + /// 카카오 컨테이너 배경 (#FEE500). + static let kakaoYellow = UIColor(red: 0xFE / 255, green: 0xE5 / 255, blue: 0x00 / 255, alpha: 1) + /// 카카오 심볼 틴트 (#181600). + static let kakaoSymbol = UIColor(red: 0x18 / 255, green: 0x16 / 255, blue: 0x00 / 255, alpha: 1) +} diff --git a/Projects/Feature/Auth/Sources/LoginCoordinator.swift b/Projects/Feature/Auth/Sources/LoginCoordinator.swift new file mode 100644 index 00000000..a291a10d --- /dev/null +++ b/Projects/Feature/Auth/Sources/LoginCoordinator.swift @@ -0,0 +1,46 @@ +import CoreCoordinator +import UIKit + +final class LoginCoordinator: Coordinator { + var childCoordinators: [any Coordinator] = [] + weak var finishDelegate: (any CoordinatorFinishDelegate)? + + // The App (window root) owns the navigation controller. + private weak var navigationController: UINavigationController? + private let container: AuthDIContainer + private let onAuthenticated: () -> Void + private weak var loginViewController: LoginViewController? + + init( + navigationController: UINavigationController, + container: AuthDIContainer, + onAuthenticated: @escaping () -> Void + ) { + self.navigationController = navigationController + self.container = container + self.onAuthenticated = onAuthenticated + } + + func start() { + guard let navigationController else { return } + let viewController = container.makeLoginViewController(onAuthenticated: { [weak self] in + self?.completeFlow() + }) + loginViewController = viewController + navigationController.present(viewController, animated: false) + } + + /// 성공 유일 종료 경로 — 시트 하강 연출 후 onAuthenticated → finish. + private func completeFlow() { + guard let loginViewController else { + onAuthenticated() + finish() + return + } + loginViewController.animateDismiss { [weak self] in + guard let self else { return } + self.onAuthenticated() + self.finish() + } + } +} diff --git a/Projects/Feature/Auth/Sources/LoginViewController.swift b/Projects/Feature/Auth/Sources/LoginViewController.swift new file mode 100644 index 00000000..544c229e --- /dev/null +++ b/Projects/Feature/Auth/Sources/LoginViewController.swift @@ -0,0 +1,172 @@ +import DesignSystem +import Domain +import SnapKit +import UIKit + +/// 레거시 로그인 바텀시트 비주얼 이식(dim 0.9 + 컨테이너 198 + 핸들 + 슬라이드업). +/// 강제 로그인이라 dim 탭·팬 다운 dismiss 제스처는 계승하지 않는다 — 핸들은 장식. +final class LoginViewController: UIViewController { + private let viewModel: LoginViewModel + + private let dimView = UIView() + private let containerView = UIView() + private let sheetHandle = UIView() + private let kakaoButton = SocialLoginButton( + title: "카카오톡으로 3초만에 시작", + icon: AuthFeatureAsset.kakao.image, + textColor: .black, + background: AuthPalette.kakaoYellow, + iconTint: AuthPalette.kakaoSymbol + ) + private let appleButton = SocialLoginButton( + title: "Apple로 시작", + icon: AuthFeatureAsset.apple.image, + textColor: .white, + background: .black, + iconTint: .white + ) + private let sheetHeight: CGFloat = 198 + + init(viewModel: LoginViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + override func viewDidLoad() { + super.viewDidLoad() + setupLayout() + bindViewModel() + containerView.transform = CGAffineTransform(translationX: 0, y: sheetHeight) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { + self.dimView.alpha = 1 + self.containerView.transform = .identity + } + } + + /// 성공 종료 연출(레거시 dismissSheet) — 시트 하강 + dim 페이드 후 실제 dismiss. + /// 코디네이터가 부른다. + func animateDismiss(completion: @escaping () -> Void) { + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn) { + self.dimView.alpha = 0 + self.containerView.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight) + } completion: { _ in + self.dismiss(animated: false, completion: completion) + } + } + + private func setupLayout() { + view.backgroundColor = .clear + + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.9) + dimView.alpha = 0 + view.addSubview(dimView) + dimView.snp.makeConstraints { $0.edges.equalToSuperview() } + + containerView.backgroundColor = DSColor.Background.elevated + containerView.layer.cornerRadius = 20 + containerView.clipsToBounds = true + view.addSubview(containerView) + containerView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(sheetHeight) + } + + sheetHandle.backgroundColor = DSColor.Fill.highlight + sheetHandle.layer.cornerRadius = 2 + containerView.addSubview(sheetHandle) + sheetHandle.snp.makeConstraints { make in + make.top.equalToSuperview().offset(DSSpacing.sm12) + make.centerX.equalToSuperview() + make.width.equalTo(40) + make.height.equalTo(4) + } + + let buttonStack = UIStackView(arrangedSubviews: [kakaoButton, appleButton]) + buttonStack.axis = .vertical + buttonStack.spacing = DSSpacing.sm12 + containerView.addSubview(buttonStack) + buttonStack.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(DSSpacing.lg) + make.bottom.equalToSuperview().inset(40) + } + + kakaoButton.addTarget(self, action: #selector(didTapKakao), for: .touchUpInside) + appleButton.addTarget(self, action: #selector(didTapApple), for: .touchUpInside) + + #if DEV + setupDevSkipButton() + #endif + } + + private func bindViewModel() { + viewModel.onStateChange = { [weak self] state in + self?.render(state) + } + viewModel.onEvent = { [weak self] event in + self?.handle(event) + } + render(viewModel.state) + } + + private func render(_ state: LoginViewModel.State) { + let isLoading = state != .idle + kakaoButton.isEnabled = !isLoading + appleButton.isEnabled = !isLoading + } + + private func handle(_ event: LoginViewModel.Event) { + switch event { + case .showSignUpNotice: + let alert = UIAlertController( + title: nil, + message: LoginViewModel.signUpNoticeMessage, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + case let .showFailureToast(message): + DSToast.show(message, in: view) + } + } + + @objc private func didTapKakao() { + viewModel.kakaoTapped() + } + + @objc private func didTapApple() { + viewModel.appleTapped() + } + + #if DEV + /// 시뮬레이터 자동 검수용 우회 진입점 — 시트 레이아웃을 건드리지 않도록 dim 상단에 둔다. + private func setupDevSkipButton() { + let skipButton = UIButton(type: .system) + skipButton.setAttributedTitle( + DSTypography.caption1.attributed("DEV 건너뛰기", color: DSColor.Text.tertiary), + for: .normal + ) + skipButton.accessibilityIdentifier = "login.devSkip" + view.addSubview(skipButton) + skipButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(DSSpacing.sm) + make.trailing.equalToSuperview().inset(DSSpacing.lg) + } + skipButton.addTarget(self, action: #selector(didTapDevSkip), for: .touchUpInside) + } + + @objc private func didTapDevSkip() { + viewModel.devSkipTapped() + } + #endif + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/Feature/Auth/Sources/LoginViewModel.swift b/Projects/Feature/Auth/Sources/LoginViewModel.swift new file mode 100644 index 00000000..a29daa2b --- /dev/null +++ b/Projects/Feature/Auth/Sources/LoginViewModel.swift @@ -0,0 +1,86 @@ +import Domain +import Foundation + +@MainActor +final class LoginViewModel { + enum State: Equatable { + case idle + /// 어느 제공자로 진행 중인지 — 화면은 양 버튼을 잠근다(중복 탭 방지). + case loading(SocialLoginProvider) + } + + /// 원샷 안내 — 상태와 분리한다(Home의 ToastEvent 패턴). + enum Event: Equatable { + case showSignUpNotice + case showFailureToast(message: String) + } + + static let failureMessage = "로그인에 실패했어요. 잠시 후 다시 시도해주세요." + static let signUpNoticeMessage = "아직 가입되지 않은 계정이에요.\n회원가입은 곧 지원될 예정이에요." + + var onStateChange: ((State) -> Void)? + var onEvent: ((Event) -> Void)? + /// 세션 채택까지 완료된 뒤에만 불린다 — 코디네이터가 플로우를 닫는 신호. + var onAuthenticated: (() -> Void)? + + private(set) var state: State = .idle { + didSet { onStateChange?(state) } + } + + private let signInUseCase: any SignInUseCase + private var signInTask: Task? + + init(signInUseCase: any SignInUseCase) { + self.signInUseCase = signInUseCase + } + + deinit { + signInTask?.cancel() + } + + func kakaoTapped() { + signIn(provider: .kakao) + } + + func appleTapped() { + signIn(provider: .apple) + } + + private func signIn(provider: SocialLoginProvider) { + guard state == .idle else { return } + state = .loading(provider) + signInTask = Task { [weak self] in + guard let self else { return } + do { + let outcome = try await self.signInUseCase.execute(provider: provider) + guard !Task.isCancelled else { return } + switch outcome { + case .success: + // 성공 시 idle 복귀 없이 바로 닫는다 — 버튼이 다시 살아나며 + // 깜빡이는 프레임을 만들지 않는다. + self.onAuthenticated?() + case .needsSignUp: + self.state = .idle + self.onEvent?(.showSignUpNotice) + } + } catch SocialLoginError.cancelled { + // 사용자가 소셜 UI를 스스로 닫음 — 에러 표출 없이 재시도 대기. + guard !Task.isCancelled else { return } + self.state = .idle + } catch { + guard !Task.isCancelled else { return } + self.state = .idle + self.onEvent?(.showFailureToast(message: Self.failureMessage)) + } + } + } + + #if DEV + /// 시뮬레이터 자동 검수용 우회(소셜 계정·카카오 키 없이 홈 진입) — + /// 토큰 없이 진행하므로 서버 기능은 DevDemoFallbacks가 가리는 기존 DEV 동작과 같다. + /// 실서버 인증 정식화 시 DevDemoFallbacks와 함께 제거한다. + func devSkipTapped() { + onAuthenticated?() + } + #endif +} diff --git a/Projects/Feature/Auth/Sources/SocialLoginButton.swift b/Projects/Feature/Auth/Sources/SocialLoginButton.swift new file mode 100644 index 00000000..bc335fb5 --- /dev/null +++ b/Projects/Feature/Auth/Sources/SocialLoginButton.swift @@ -0,0 +1,53 @@ +import DesignSystem +import SnapKit +import UIKit + +/// 레거시 로그인 버튼 스펙 이식: 높이 52 · radius 8 · 아이콘 leading 16(24pt) · 라벨 중앙. +/// DSButton은 아이콘을 타이틀에 인접 배치하는 Configuration 기반이라 이 스펙(아이콘 +/// 고정 + 라벨 독립 중앙)에는 전용 컴포넌트가 맞다. +final class SocialLoginButton: UIButton { + static let height: CGFloat = 52 + + private let iconView = UIImageView() + private let label = UILabel() + + init(title: String, icon: UIImage?, textColor: UIColor, background: UIColor, iconTint: UIColor) { + super.init(frame: .zero) + + backgroundColor = background + layer.cornerRadius = DSRadius.sm + clipsToBounds = true + + iconView.image = icon + iconView.contentMode = .scaleAspectFit + iconView.tintColor = iconTint + iconView.isUserInteractionEnabled = false + + label.attributedText = DSTypography.label1.attributed(title, color: textColor, alignment: .center) + label.isUserInteractionEnabled = false + + addSubview(iconView) + addSubview(label) + + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(DSSpacing.md) + make.centerY.equalToSuperview() + make.width.height.equalTo(DSIconSize.lg) + } + label.snp.makeConstraints { make in + make.center.equalToSuperview() + } + snp.makeConstraints { make in + make.height.equalTo(Self.height) + } + } + + override var isEnabled: Bool { + didSet { alpha = isEnabled ? 1 : 0.5 } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/Feature/Auth/Tests/LoginViewModelTests.swift b/Projects/Feature/Auth/Tests/LoginViewModelTests.swift new file mode 100644 index 00000000..fad6c62a --- /dev/null +++ b/Projects/Feature/Auth/Tests/LoginViewModelTests.swift @@ -0,0 +1,147 @@ +@testable import AuthFeature +import Domain +import Foundation +import Testing + +@MainActor +private final class StubSignInUseCase: SignInUseCase { + private(set) var executeCallCount = 0 + private(set) var providers: [SocialLoginProvider] = [] + private var result: Result + private let suspends: Bool + private var continuation: CheckedContinuation? + + init(result: Result = .success(.success), suspends: Bool = false) { + self.result = result + self.suspends = suspends + } + + func execute(provider: SocialLoginProvider) async throws -> SignInOutcome { + executeCallCount += 1 + providers.append(provider) + if suspends { + return try await withCheckedThrowingContinuation { continuation = $0 } + } + return try result.get() + } + + func resume(with outcome: SignInOutcome) { + continuation?.resume(returning: outcome) + continuation = nil + } +} + +@MainActor +private final class Recorder { + private(set) var states: [LoginViewModel.State] = [] + private(set) var events: [LoginViewModel.Event] = [] + private(set) var authenticatedCount = 0 + + func attach(to viewModel: LoginViewModel) { + viewModel.onStateChange = { [weak self] state in self?.states.append(state) } + viewModel.onEvent = { [weak self] event in self?.events.append(event) } + viewModel.onAuthenticated = { [weak self] in self?.authenticatedCount += 1 } + } + + // Home 템플릿의 드레인 패턴: 스텁이 즉시 resolve하므로 yield로 충분하다. + func waitUntil(_ predicate: () -> Bool) async { + while !predicate() { + await Task.yield() + } + } +} + +private struct StubError: Error {} + +@MainActor +struct LoginViewModelTests { + @Test + func kakaoTapped_success_callsOnAuthenticatedWithoutIdleFlicker() async { + let useCase = StubSignInUseCase(result: .success(.success)) + let sut = LoginViewModel(signInUseCase: useCase) + let recorder = Recorder() + recorder.attach(to: sut) + + sut.kakaoTapped() + await recorder.waitUntil { recorder.authenticatedCount == 1 } + + #expect(useCase.providers == [.kakao]) + // 성공 시 idle 복귀 없이 바로 닫힌다 — 버튼 재활성 프레임 없음. + #expect(recorder.states == [.loading(.kakao)]) + #expect(recorder.events.isEmpty) + } + + @Test + func appleTapped_passesAppleProvider() async { + let useCase = StubSignInUseCase(result: .success(.success)) + let sut = LoginViewModel(signInUseCase: useCase) + let recorder = Recorder() + recorder.attach(to: sut) + + sut.appleTapped() + await recorder.waitUntil { recorder.authenticatedCount == 1 } + + #expect(useCase.providers == [.apple]) + } + + @Test + func tapped_needsSignUp_returnsIdleAndEmitsNotice() async { + let useCase = StubSignInUseCase(result: .success(.needsSignUp)) + let sut = LoginViewModel(signInUseCase: useCase) + let recorder = Recorder() + recorder.attach(to: sut) + + sut.kakaoTapped() + await recorder.waitUntil { recorder.states.last == .idle } + + #expect(recorder.events == [.showSignUpNotice]) + #expect(recorder.authenticatedCount == 0) + } + + @Test + func tapped_socialCancelled_returnsIdleSilently() async { + let useCase = StubSignInUseCase(result: .failure(SocialLoginError.cancelled)) + let sut = LoginViewModel(signInUseCase: useCase) + let recorder = Recorder() + recorder.attach(to: sut) + + sut.kakaoTapped() + await recorder.waitUntil { recorder.states.last == .idle } + + #expect(recorder.events.isEmpty) + #expect(recorder.authenticatedCount == 0) + } + + @Test + func tapped_failure_returnsIdleAndEmitsFailureToast() async { + let useCase = StubSignInUseCase(result: .failure(StubError())) + let sut = LoginViewModel(signInUseCase: useCase) + let recorder = Recorder() + recorder.attach(to: sut) + + sut.appleTapped() + await recorder.waitUntil { recorder.states.last == .idle } + + #expect(recorder.events == [.showFailureToast(message: LoginViewModel.failureMessage)]) + #expect(recorder.authenticatedCount == 0) + } + + /// 로딩 중 중복 탭은 무시된다 — 소셜 UI 이중 기동 방지. + @Test + func tapped_whileLoading_ignoresSecondTap() async { + let useCase = StubSignInUseCase(suspends: true) + let sut = LoginViewModel(signInUseCase: useCase) + let recorder = Recorder() + recorder.attach(to: sut) + + sut.kakaoTapped() + await recorder.waitUntil { useCase.executeCallCount == 1 } + sut.appleTapped() + + #expect(useCase.executeCallCount == 1) + #expect(recorder.states == [.loading(.kakao)]) + + useCase.resume(with: .success) + await recorder.waitUntil { recorder.authenticatedCount == 1 } + } +} diff --git a/Workspace.swift b/Workspace.swift index d2bc2dee..9ca45f99 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -4,6 +4,7 @@ let workspace = Workspace( name: "Atcha", projects: [ "Projects/App", + "Projects/Feature/Auth", "Projects/Feature/Home", "Projects/Feature/Search", "Projects/Domain", From 693f9b850cec154d17f7604815c5abee71ad96e2 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 13 Sep 2026 13:20:18 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20App=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=E2=80=94=20=EA=B0=95=EC=A0=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=C2=B7=EC=86=8C=EC=85=9C=20SDK=20?= =?UTF-8?q?=EC=96=B4=EB=8C=91=ED=84=B0=C2=B7=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppCoordinator: bootstrapState 분기(loginRequired→스플래시 위 로그인 시트), sessionExpired 구독(만료 시 홈 접고 재로그인, 재로그인 후 syncNow 1회) - SocialLoginAdapter: 카카오(톡/계정 분기, 취소 매핑) + Apple(continuation take() 패턴, 활성 씬 anchor — 레거시 force unwrap 제거) - AuthSessionStoreAdapter(SessionStoring→adopt), FCMPushTokenAdapter(Firebase 미구성 시 nil) - AppDIContainer: plainNetworkClient 승격, AuthRepositoryImpl(plain) + makeAuthDIContainer - Project.swift: Kakao SDK 3종 링크(앱 타겟 전용), applesignin entitlement, TUIST_KAKAO_APP_KEY env→Info.plist(키 없으면 URL 스킴 미등록), LSApplicationQueriesSchemes - AppDelegate KakaoSDK.initSDK(키 존재 시) / SceneDelegate openURLContexts 카카오 복귀 처리 Co-Authored-By: Claude Fable 5 --- Projects/App/Project.swift | 70 +++++++--- .../Adapters/AuthSessionStoreAdapter.swift | 14 ++ .../Adapters/FCMPushTokenAdapter.swift | 14 ++ .../Sources/Adapters/SocialLoginAdapter.swift | 127 ++++++++++++++++++ Projects/App/Sources/AppCoordinator.swift | 63 ++++++++- Projects/App/Sources/AppDIContainer.swift | 18 +++ Projects/App/Sources/AppDelegate.swift | 5 + Projects/App/Sources/KakaoConfig.swift | 12 ++ Projects/App/Sources/SceneDelegate.swift | 7 + 9 files changed, 306 insertions(+), 24 deletions(-) create mode 100644 Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift create mode 100644 Projects/App/Sources/Adapters/FCMPushTokenAdapter.swift create mode 100644 Projects/App/Sources/Adapters/SocialLoginAdapter.swift create mode 100644 Projects/App/Sources/KakaoConfig.swift diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 4074bba7..a14f5c86 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -1,34 +1,51 @@ import ProjectDescription import ProjectDescriptionHelpers +// 카카오 네이티브 앱 키 — xcconfig 금지 규약(AtchaV2) 하에서 env(TUIST_KAKAO_APP_KEY)로 +// 주입한다(mise [env] 또는 CI secret). 미주입이면 빈 문자열: generate/빌드는 통과하고 +// 카카오 로그인만 런타임 비활성(providerUnavailable → 실패 토스트)이다. +let kakaoAppKey = Environment.kakaoAppKey.getString(default: "") + +var appInfoPlist: [String: Plist.Value] = [ + "CFBundleDisplayName": "앗차", + "UILaunchStoryboardName": "LaunchScreen", + "NSAlarmKitUsageDescription": "막차 시간에 맞춰 알람을 울리기 위해 권한이 필요합니다.", + "NSLocationWhenInUseUsageDescription": "현재 위치를 출발지로 사용하기 위해 위치 정보 접근 권한이 필요합니다.", + // 사일런트 푸시(content-available=1) 수신용 — 사용자 알림 권한과 무관. + "UIBackgroundModes": ["remote-notification"], + "NSSupportsLiveActivities": true, + "UIApplicationSceneManifest": [ + "UIApplicationSupportsMultipleScenes": false, + "UISceneConfigurations": [ + "UIWindowSceneSessionRoleApplication": [ + [ + "UISceneConfigurationName": "Default", + "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate", + ], + ], + ], + ], + "UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"], + "ITSAppUsesNonExemptEncryption": false, + // 카카오톡 로그인 앱 전환 조회용(레거시 Info.plist 실측). + "LSApplicationQueriesSchemes": ["kakaokompassauth", "kakaolink"], + // 런타임(KakaoConfig)이 읽는 키 — 빈 값이면 KakaoSDK.initSDK를 건너뛴다. + "KAKAO_APP_KEY": .string(kakaoAppKey), +] +// 키 미주입 시 무의미한 `kakao` 빈 스킴이 등록되지 않도록 조건 분기. +if !kakaoAppKey.isEmpty { + appInfoPlist["CFBundleURLTypes"] = [ + ["CFBundleURLSchemes": ["kakao\(kakaoAppKey)"]], + ] +} + let appTarget = Target.target( name: "AtchaV2", destinations: Atcha.destinations, product: .app, bundleId: Atcha.v2BundleID, deploymentTargets: Atcha.v2Deployment, - infoPlist: .extendingDefault(with: [ - "CFBundleDisplayName": "앗차", - "UILaunchStoryboardName": "LaunchScreen", - "NSAlarmKitUsageDescription": "막차 시간에 맞춰 알람을 울리기 위해 권한이 필요합니다.", - "NSLocationWhenInUseUsageDescription": "현재 위치를 출발지로 사용하기 위해 위치 정보 접근 권한이 필요합니다.", - // 사일런트 푸시(content-available=1) 수신용 — 사용자 알림 권한과 무관. - "UIBackgroundModes": ["remote-notification"], - "NSSupportsLiveActivities": true, - "UIApplicationSceneManifest": [ - "UIApplicationSupportsMultipleScenes": false, - "UISceneConfigurations": [ - "UIWindowSceneSessionRoleApplication": [ - [ - "UISceneConfigurationName": "Default", - "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate", - ], - ], - ], - ], - "UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"], - "ITSAppUsesNonExemptEncryption": false, - ]), + infoPlist: .extendingDefault(with: appInfoPlist), sources: ["Sources/**"], resources: ["Resources/**"], // APNs 등록에 필수 (없으면 didFailToRegister: "aps-environment 없음"). @@ -37,9 +54,13 @@ let appTarget = Target.target( "aps-environment": "development", // 폴백 노티의 .timeSensitive interruptionLevel용(Phase 15) — 집중 모드 관통. "com.apple.developer.usernotifications.time-sensitive": true, + // 소셜 로그인(Apple) — App ID capability 활성 + 프로비저닝 갱신 필요(콘솔 작업). + "com.apple.developer.applesignin": ["Default"], ]), dependencies: [ .target(name: "AtchaWidget"), + .project(target: "AuthFeature", path: "../Feature/Auth"), + .project(target: "AuthFeatureInterface", path: "../Feature/Auth"), .project(target: "HomeFeature", path: "../Feature/Home"), .project(target: "HomeFeatureInterface", path: "../Feature/Home"), .project(target: "SearchFeature", path: "../Feature/Search"), @@ -56,6 +77,11 @@ let appTarget = Target.target( .external(name: "FirebaseCore"), .external(name: "FirebaseCrashlytics"), .external(name: "FirebaseMessaging"), + // 소셜 SDK는 앱 타겟에서만 링크(내부 모듈 static framework 중복 심볼 방지 규약) + // — import 지점은 SocialLoginAdapter·AppDelegate·SceneDelegate뿐. + .external(name: "KakaoSDKCommon"), + .external(name: "KakaoSDKAuth"), + .external(name: "KakaoSDKUser"), ], settings: .atchaV2(base: [ // Firebase static libraries under XcodeProj-based integration need diff --git a/Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift b/Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift new file mode 100644 index 00000000..2a37ed3e --- /dev/null +++ b/Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift @@ -0,0 +1,14 @@ +import CoreAuth +import Domain + +/// `SessionStoring` → `AuthSessionManager.adopt` 배선 — Domain이 CoreAuth를 모른 채 +/// 로그인 결과 토큰을 세션에 채택시키는 다리. +struct AuthSessionStoreAdapter: SessionStoring { + let sessionManager: AuthSessionManager + + func store(accessToken: String, refreshToken: String) async throws { + try await sessionManager.adopt( + TokenPair(accessToken: accessToken, refreshToken: refreshToken) + ) + } +} diff --git a/Projects/App/Sources/Adapters/FCMPushTokenAdapter.swift b/Projects/App/Sources/Adapters/FCMPushTokenAdapter.swift new file mode 100644 index 00000000..f629ed42 --- /dev/null +++ b/Projects/App/Sources/Adapters/FCMPushTokenAdapter.swift @@ -0,0 +1,14 @@ +import Domain +import FirebaseCore +import FirebaseMessaging + +/// `/auth/login`의 fcmToken 소싱 — V2는 GoogleService-Info.plist가 레거시 번들용이라 +/// Firebase 미구성이 보통이고, 그때는 nil(→ 엔드포인트가 빈 문자열 전송, 레거시 관용). +/// FCM 토큰 서버 전달 정식화는 기존 미확정 #5 그대로. +struct FCMPushTokenAdapter: PushTokenProviding { + func currentPushToken() async -> String? { + // configure() 전에 Messaging.messaging()을 만지면 크래시 — 구성 여부로 가드. + guard FirebaseApp.app() != nil else { return nil } + return Messaging.messaging().fcmToken + } +} diff --git a/Projects/App/Sources/Adapters/SocialLoginAdapter.swift b/Projects/App/Sources/Adapters/SocialLoginAdapter.swift new file mode 100644 index 00000000..b1f833b4 --- /dev/null +++ b/Projects/App/Sources/Adapters/SocialLoginAdapter.swift @@ -0,0 +1,127 @@ +import AuthenticationServices +import Domain +import KakaoSDKAuth +import KakaoSDKCommon +import KakaoSDKUser +import UIKit + +/// 소셜 SDK를 아는 곳은 이 어댑터뿐(외부 라이브러리 앱 타겟 전용 링크 규약). +/// 레거시 `LoginUseCaseImpl`의 delegate 이중 보관·`self.delegate!`·`view.window!` +/// force unwrap을 제거한 async 래핑 — 진행 중 continuation은 take()(nil 교체 후 사용)로 +/// 이중 resume을 차단한다. +@MainActor +final class SocialLoginAdapter: NSObject, SocialLoginService { + // 진행 중 Apple 플로우 — 어댑터가 delegate로 살아 있는 동안 controller도 함께 보관. + private var appleContinuation: CheckedContinuation? + private var appleController: ASAuthorizationController? + + func authorize(provider: SocialLoginProvider) async throws -> SocialCredential { + switch provider { + case .kakao: try await authorizeKakao() + case .apple: try await authorizeApple() + } + } + + // MARK: - Kakao + + private func authorizeKakao() async throws -> SocialCredential { + // initSDK가 안 된 상태(키 미주입)에서 UserApi를 부르면 SDK가 fatal — 선차단. + guard KakaoConfig.appKey != nil else { throw SocialLoginError.providerUnavailable } + let accessToken: String = try await withCheckedThrowingContinuation { continuation in + let completion: (OAuthToken?, (any Error)?) -> Void = { oauthToken, error in + if let error { + continuation.resume(throwing: Self.mapKakaoError(error)) + } else if let accessToken = oauthToken?.accessToken { + continuation.resume(returning: accessToken) + } else { + continuation.resume(throwing: SocialLoginError.failed) + } + } + // 카카오톡 앱 설치 시 앱 전환, 아니면 계정 웹뷰 — 레거시 분기 그대로. + if UserApi.isKakaoTalkLoginAvailable() { + UserApi.shared.loginWithKakaoTalk(completion: completion) + } else { + UserApi.shared.loginWithKakaoAccount(completion: completion) + } + } + return SocialCredential(provider: .kakao, accessToken: accessToken) + } + + private static func mapKakaoError(_ error: any Error) -> any Error { + // 사용자 취소는 조용한 idle 복귀 대상 — 그 외는 실패 토스트로 표면화. + if let sdkError = error as? SdkError, + case let .ClientFailed(reason, _) = sdkError, + reason == .Cancelled { + return SocialLoginError.cancelled + } + return error + } + + // MARK: - Apple + + private func authorizeApple() async throws -> SocialCredential { + // ViewModel의 로딩 가드가 1차 방어지만, 플로우 단일성은 여기서도 보장한다. + guard appleContinuation == nil else { throw SocialLoginError.failed } + let request = ASAuthorizationAppleIDProvider().createRequest() + request.requestedScopes = [.fullName, .email] + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + appleController = controller + return try await withCheckedThrowingContinuation { continuation in + appleContinuation = continuation + controller.performRequests() + } + } + + private func takeAppleContinuation() -> CheckedContinuation? { + defer { + appleContinuation = nil + appleController = nil + } + return appleContinuation + } +} + +extension SocialLoginAdapter: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard let continuation = takeAppleContinuation() else { return } + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, + let tokenData = credential.identityToken, + let identityToken = String(data: tokenData, encoding: .utf8) + else { + continuation.resume(throwing: SocialLoginError.failed) + return + } + continuation.resume( + returning: SocialCredential(provider: .apple, accessToken: identityToken) + ) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: any Error + ) { + guard let continuation = takeAppleContinuation() else { return } + if let authorizationError = error as? ASAuthorizationError, + authorizationError.code == .canceled { + continuation.resume(throwing: SocialLoginError.cancelled) + } else { + continuation.resume(throwing: error) + } + } +} + +extension SocialLoginAdapter: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + // 레거시의 `view.window!` 대체 — 호출 시점의 활성 씬 key window를 해석하고, + // 끝내 없으면 빈 앵커로 강제 크래시만 피한다(시트가 안 뜰 뿐). + let scene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + return scene?.keyWindow ?? scene?.windows.first ?? ASPresentationAnchor() + } +} diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index a5462e2a..fcd4fad8 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -10,17 +10,28 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { private let container: AppDIContainer private weak var splashViewController: SplashViewController? + private var sessionExpiryTask: Task? + /// 로그인 플로우 표시 중 가드 — 만료 스트림의 중복 yield에 로그인을 겹치지 않는다. + private var isShowingLogin = false + /// 세션 만료 경유 재로그인이면 성공 직후 수동 동기화 1회 — activate()의 시작 + /// 동기화는 최초 1회 가드라 재로그인 경로에선 돌지 않기 때문. + private var needsSyncAfterLogin = false init(navigationController: UINavigationController, container: AppDIContainer) { self.navigationController = navigationController self.container = container } + deinit { + sessionExpiryTask?.cancel() + } + func start() { let splash = SplashViewController() splash.onRetryTapped = { [weak self] in self?.bootstrap() } splashViewController = splash navigationController.setViewControllers([splash], animated: false) + observeSessionExpiry() bootstrap() } @@ -33,11 +44,59 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { // 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만. container.alarmSyncService.activate() case .loginRequired: - // 임시(커밋 1): AuthFeature 통합 커밋에서 로그인 시트 present로 교체. - splashViewController?.showRetry(message: "로그인이 필요해요") + showLogin() + } + } + + /// 강제 로그인 — 스플래시를 root로 유지한 채 로그인 시트를 present한다 + /// (스플래시 배경 위 바텀시트 = 레거시와 같은 시각 결과). + private func showLogin() { + guard !isShowingLogin else { return } + isShowingLogin = true + let loginCoordinator = container.makeAuthDIContainer().makeLoginCoordinator( + navigationController: navigationController, + onAuthenticated: { [weak self] in + guard let self else { return } + self.isShowingLogin = false + self.startHome() + self.container.alarmSyncService.activate() + if self.needsSyncAfterLogin { + self.needsSyncAfterLogin = false + let syncService = self.container.alarmSyncService + Task { await syncService.syncNow() } + } + } + ) + loginCoordinator.finishDelegate = self + addChild(loginCoordinator) + loginCoordinator.start() + } + + /// refresh 확정 사망(AuthSessionManager.sessionExpired) 관찰 — 홈을 접고 스플래시 + /// 위 로그인으로 되돌린다. 스트림은 단일 소비자(이 코디네이터) 전제. + private func observeSessionExpiry() { + sessionExpiryTask = Task { [weak self] in + guard let stream = self?.container.authSessionManager.sessionExpired else { return } + for await _ in stream { + guard let self, !Task.isCancelled else { return } + self.handleSessionExpiry() + } } } + private func handleSessionExpiry() { + guard !isShowingLogin else { return } + needsSyncAfterLogin = true + navigationController.presentedViewController?.dismiss(animated: false) + // setViewControllers가 didShow를 태워 SearchCoordinator류의 정리 경로도 돈다. + let splash = SplashViewController() + splash.onRetryTapped = { [weak self] in self?.bootstrap() } + splashViewController = splash + navigationController.setViewControllers([splash], animated: false) + childCoordinators.removeAll() + showLogin() + } + private func startHome() { let homeCoordinator = container.makeHomeDIContainer() .makeHomeCoordinator(navigationController: navigationController) diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index a6f8f64f..b559df21 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -1,4 +1,6 @@ import AtchaData +import AuthFeature +import AuthFeatureInterface import CoreAlarm import CoreAuth import CoreNetwork @@ -14,6 +16,9 @@ import SearchFeatureInterface /// Presentation modules depend on Domain protocols only. final class AppDIContainer { private let networkClient: any NetworkClient + /// 데코레이터 미적용 클라이언트 — reissue(AuthSessionManager)와 소셜 Bearer를 실어야 + /// 하는 로그인 API(AuthRepositoryImpl)만 쓴다. + private let plainNetworkClient: any NetworkClient let authSessionManager: AuthSessionManager // 알람 스택은 1회 생성해 공유한다 — AlarmSyncService(갱신 일원화)와 @@ -66,6 +71,7 @@ final class AppDIContainer { session: URLSession(configuration: sessionConfiguration) ) #endif + self.plainNetworkClient = baseClient let sessionManager = AuthSessionManager( tokenStore: TokenStore(store: KeychainStore()), // The plain client, not the decorator — reissue must never recurse @@ -143,6 +149,18 @@ final class AppDIContainer { await liveActivityAdapter.reattachOrphans(snapshot: snapshot, now: Date()) } + func makeAuthDIContainer() -> any AuthCoordinatorBuildable { + AuthDIContainer( + signInUseCase: DefaultSignInUseCase( + socialLoginService: SocialLoginAdapter(), + // plain client — 소셜 Bearer 보존 + 401이 세션 복구를 촉발하지 않게. + authRepository: AuthRepositoryImpl(networkClient: plainNetworkClient), + sessionStore: AuthSessionStoreAdapter(sessionManager: authSessionManager), + pushTokenProvider: FCMPushTokenAdapter() + ) + ) + } + func makeHomeDIContainer() -> any HomeCoordinatorBuildable { let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore()) let locationService = CoreLocationServiceAdapter() diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index c9a391e4..60952d65 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -1,5 +1,6 @@ import FirebaseCore import FirebaseMessaging +import KakaoSDKCommon import UIKit import os @@ -27,6 +28,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { // 노티 탭 라우팅(Phase 15) — launch 완료 전에 등록해야 탭이 앱을 cold start시키는 // 경우의 didReceive까지 잡는다. 홈 랜딩 훅 배선은 SceneDelegate 몫. container.notificationTapDelegate.attachToNotificationCenter() + // 카카오 로그인 — 키 미주입(TUIST_KAKAO_APP_KEY 부재) 빌드에선 건너뛴다. + if let kakaoAppKey = KakaoConfig.appKey { + KakaoSDK.initSDK(appKey: kakaoAppKey) + } configureFirebaseIfAvailable() if isFirebaseEnabled { Messaging.messaging().delegate = self diff --git a/Projects/App/Sources/KakaoConfig.swift b/Projects/App/Sources/KakaoConfig.swift new file mode 100644 index 00000000..782040eb --- /dev/null +++ b/Projects/App/Sources/KakaoConfig.swift @@ -0,0 +1,12 @@ +import Foundation + +/// 카카오 네이티브 앱 키 — Tuist env(TUIST_KAKAO_APP_KEY)가 Info.plist로 주입한 값을 +/// 읽는다(xcconfig 금지 규약). 미주입(빈 값)이면 nil — 카카오 로그인만 비활성. +enum KakaoConfig { + static var appKey: String? { + guard let key = Bundle.main.object(forInfoDictionaryKey: "KAKAO_APP_KEY") as? String, + !key.isEmpty + else { return nil } + return key + } +} diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index de37ac44..7cbb9acf 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -1,3 +1,4 @@ +import KakaoSDKAuth import UIKit final class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -35,6 +36,12 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { #endif } + /// 카카오톡 앱 로그인 리디렉션(kakao{KEY}:// 스킴) 복귀 처리 — 레거시 동일. + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let url = URLContexts.first?.url, AuthApi.isKakaoTalkLoginUrl(url) else { return } + _ = AuthController.handleOpenUrl(url: url) + } + #if DEV // MARK: - Phase 11 변경 시뮬레이터 (DEV 한정 플로팅 버튼) From ebf4f878f8634f4c37c0a9a5d19a4d0fd5cd7f4e Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 13 Sep 2026 13:21:00 +0900 Subject: [PATCH 08/13] =?UTF-8?q?docs:=20=EC=9D=B8=EC=A6=9D=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=A0=95=ED=95=A9=20=E2=80=94=20=EC=9D=B5=EB=AA=85?= =?UTF-8?q?=20=EC=84=B8=EC=85=98(S1/#2)=EC=9D=84=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=8C=80=EC=B2=B4=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=EB=A1=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- docs/planning/atcha-v2-post12-roadmap.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3b8e65fb..87eb34ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho ### 미완 상태 (작업 시 참고) -- **익명 인증 발급 엔드포인트 미확정** — `UnconfiguredAnonymousSessionIssuer` 스텁이 주입돼 있어 실서버에서는 토큰 없이 동작한다(알람 서버 기능 전부 불가). Debug(DEV)는 `DevDemoFallbacks`가 데모 데이터로 가린다 — 실서버 스펙 확정 시 이 폴백과 `AppDIContainer`의 `#if DEV` 주입을 제거할 것. +- **인증은 소셜 로그인(카카오·애플)** — 익명 세션 스텁은 제거됐고, 강제 로그인(게스트 없음)으로 `AuthFeature`가 최초 토큰을 발급받는다(`GET /auth/check`·`/auth/login`, 소셜 토큰을 Bearer로 — plain client 전용). 카카오 키는 `TUIST_KAKAO_APP_KEY` env로 주입(미주입 시 카카오 버튼만 런타임 비활성, DEV는 로그인 시트의 "DEV 건너뛰기"로 우회 가능). 잔여: 카카오/Apple 콘솔에 V2 번들 등록, 서버 audience 허용 확인. Debug(DEV)의 `DevDemoFallbacks`는 실서버 인증 검증 후 제거. - `AppEnvironment`의 base URL은 dev/live 실주소 반영 완료. **Stage는 dev 호스트를 공유 중** — 전용 호스트만 미정. - `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰 서버 전달도 미구현(로깅만)이라 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)과 홈 pull-to-refresh(수동)뿐. - AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료. diff --git a/docs/planning/atcha-v2-post12-roadmap.md b/docs/planning/atcha-v2-post12-roadmap.md index 969170d6..94bc76a2 100644 --- a/docs/planning/atcha-v2-post12-roadmap.md +++ b/docs/planning/atcha-v2-post12-roadmap.md @@ -32,7 +32,7 @@ Phase 1~12로 "검색 → 알람 등록 → 변경 인지"의 전반부는 완 | # | 항목 | 클라 측 잔여 작업 | |---|---|---| -| **S1** | **익명 인증 발급 엔드포인트 (#2)** — 전 기능의 전제. 현재 `UnconfiguredAnonymousSessionIssuer` 스텁이 항상 throw하고 `bootstrap()`이 non-fatal로 삼켜 **토큰 없이 홈 진입 → 알람 서버 기능 전부 실서버에서 불가** | 구현체 1개 작성 + `AppDIContainer` 주입 교체 + `DevDemoFallbacks` 제거 (제거 조건이 파일에 명시돼 있음) | +| **S1** | ~~익명 인증 발급 엔드포인트 (#2)~~ — **소셜 로그인으로 대체 완료(2026-09)**: 익명 발급 개념을 접고 카카오·애플 로그인(`GET /auth/check`·`/auth/login`, 레거시 실측 계약)이 최초 토큰 소스가 됐다. 강제 로그인(게스트 없음), refresh 확정 거절 시 `loginRequired` 표면화 | 잔여: 카카오 콘솔에 `com.atcha.iOS.v2` 등록 + `TUIST_KAKAO_APP_KEY` 주입, Apple 콘솔 capability, 서버의 V2 번들 audience 허용 확인, `DevDemoFallbacks` 제거(실서버 검증 후) | | **S2** | **`com.atcha.iOS.v2`용 GoogleService-Info.plist 재발급 (#6·#10)** — 현재 커밋된 plist는 **레거시 번들 ID(`com.atcha.iOS`)**라 존재 가드만 무력화된 채 사일런트 푸시가 성립하지 않는다. **레거시 plist는 제거 대상.** + **FCM 토큰 전달 API (#5)** — 현재 로깅만 | plist 교체, 토큰 전달 호출 1개 (`AppDelegate` TODO 지점) | | **S3** | **responseCode 실측 (#3)** — `normalizedResult(code:)`가 빈 매핑이라 `.noRoute`가 도달 불가 상태 (빈 목록 = 무조건 `serviceEnded`). 레거시 단서: `URT_001, LRT_001, LRT_003, REQ_004` (의미 미확인). + **"등록된 알람 없음" 표현 (#11, 신규)** — 미정이면 서버에서 알람이 사라져도 홈의 배너·해제 버튼이 정리되지 않는 유령 알람 잔존 | 매핑 테이블 채우기, `refresh()`가 "없음"을 에러가 아닌 상태로 반환하게 수정 + 홈 정리 이벤트 | | **S4** | 알람 기준 시각 서버 필드 (#7 — Phase 14의 클라 임시안으로 급함 하락), 단일 알람 규약 (#4), Stage 전용 호스트 (#1 잔여), **"다음 운행 안내" 필드 (#12, 신규)** — 기획서가 "막차 종료 시 다음 운행 안내"를 약속했으나 어떤 DTO에도 필드가 없음, 대안(심야버스) 데이터 (#9) | 각 TODO 지점에 반영 | @@ -58,7 +58,7 @@ Phase 1~12로 "검색 → 알람 등록 → 변경 인지"의 전반부는 완 | # | 항목 | 상태 (2026-08-23) | |---|---|---| | 1 | 실서버 base URL | **부분 해결** — dev/live 실주소 반영(`AppEnvironment.swift`, 레거시 trust-evaluator에서 복원·사용자 승인). Stage는 dev 호스트 공유 — 전용 호스트만 미정 | -| 2 | 익명 인증 발급 엔드포인트 | **미해결 — 최치명 (S1)** | +| 2 | 익명 인증 발급 엔드포인트 | **대체 해결 (2026-09)** — 소셜 로그인(카카오·애플)이 최초 토큰 소스. 익명 발급 스펙 질문은 철회 | | 3 | responseCode 실측 | **미해결 (S3)** — `.noRoute` 도달 불가 상태 | | 4 | 단일 알람 규약 | 미해결 — 클라 refresh→cancel→register 우회 동작 중 | | 5 | FCM 토큰 전달 | **미해결 (S2)** — 로깅만 | From 9352272b2750d028c97abe6358ddf2302702cb8d Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 13 Sep 2026 16:41:34 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20Domain=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=88=98=EC=A7=81=20=EC=8A=AC=EB=9D=BC=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=90=EB=8F=99=20=EC=B5=9C=EC=86=8C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=C2=B7=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83/=ED=83=88?= =?UTF-8?q?=ED=87=B4/=EC=9C=A0=EC=A0=80=EC=A0=95=EB=B3=B4=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SignInUseCase: needsSignUp 데드엔드 제거 — 미가입이면 현재 위치(8초 타임아웃 레이스)+역지오코딩 주소로 자동 sign-up까지 수행하고 Void 반환 - SignUpForm: 레거시 실측 기본값(userName ""·address ""·(0,0)·알림빈도 [1,10]) - UserProfile 엔티티 + UserRepository 포트(/members/me 계열 4메서드) - SessionEnding 포트 + Logout/Withdraw/GetUserProfile/UpdateHomeAddress/ UpdateAlertFrequency UseCase — 탈퇴는 서버 성공 후에만 로컬 세션 무효화 - AuthRepository에 signUp 추가, 테스트 갱신·신설 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VEZMsiwwexokUxJBEk2C1A --- .../Domain/Sources/Entities/SignUpForm.swift | 32 +++++ .../Domain/Sources/Entities/UserProfile.swift | 26 +++++ .../Sources/Interfaces/AuthRepository.swift | 4 +- .../Sources/Interfaces/SessionEnding.swift | 8 ++ .../Sources/Interfaces/UserRepository.swift | 11 ++ .../UseCases/GetUserProfileUseCase.swift | 15 +++ .../Sources/UseCases/LogoutUseCase.swift | 16 +++ .../Sources/UseCases/SignInUseCase.swift | 76 ++++++++---- .../UpdateAlertFrequencyUseCase.swift | 15 +++ .../UseCases/UpdateHomeAddressUseCase.swift | 15 +++ .../Sources/UseCases/WithdrawUseCase.swift | 20 ++++ .../Tests/DefaultLogoutUseCaseTests.swift | 23 ++++ .../Tests/DefaultSignInUseCaseTests.swift | 109 ++++++++++++++++-- .../Tests/DefaultWithdrawUseCaseTests.swift | 74 ++++++++++++ 14 files changed, 413 insertions(+), 31 deletions(-) create mode 100644 Projects/Domain/Sources/Entities/SignUpForm.swift create mode 100644 Projects/Domain/Sources/Entities/UserProfile.swift create mode 100644 Projects/Domain/Sources/Interfaces/SessionEnding.swift create mode 100644 Projects/Domain/Sources/Interfaces/UserRepository.swift create mode 100644 Projects/Domain/Sources/UseCases/GetUserProfileUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/LogoutUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/UpdateAlertFrequencyUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/UpdateHomeAddressUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/WithdrawUseCase.swift create mode 100644 Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift create mode 100644 Projects/Domain/Tests/DefaultWithdrawUseCaseTests.swift diff --git a/Projects/Domain/Sources/Entities/SignUpForm.swift b/Projects/Domain/Sources/Entities/SignUpForm.swift new file mode 100644 index 00000000..4d613c0a --- /dev/null +++ b/Projects/Domain/Sources/Entities/SignUpForm.swift @@ -0,0 +1,32 @@ +/// `POST /auth/sign-up` 입력 — V2는 온보딩 화면 없이 최소 가입으로 보낸다. +/// 집 주소·알림 빈도는 가입 후 `/members/me` PATCH로 언제든 바꿀 수 있다. +public struct SignUpForm: Sendable, Equatable { + public let userName: String? + public let address: String + public let coordinate: Coordinate + public let alertFrequencies: [Int] + + public init( + userName: String?, + address: String, + coordinate: Coordinate, + alertFrequencies: [Int] + ) { + self.userName = userName + self.address = address + self.coordinate = coordinate + self.alertFrequencies = alertFrequencies + } + + /// 레거시 실측 기본 알림 빈도(HomeFindViewModel): 1분·10분 전. + public static let defaultAlertFrequencies = [1, 10] + + /// 위치를 확보하지 못했을 때의 폼 — 레거시가 실제로 보내던 값 + /// (userName ""·address ""·좌표 (0,0))이라 서버 수용이 실측돼 있다. + public static let minimalFallback = SignUpForm( + userName: "", + address: "", + coordinate: Coordinate(latitude: 0, longitude: 0), + alertFrequencies: defaultAlertFrequencies + ) +} diff --git a/Projects/Domain/Sources/Entities/UserProfile.swift b/Projects/Domain/Sources/Entities/UserProfile.swift new file mode 100644 index 00000000..4fb513d2 --- /dev/null +++ b/Projects/Domain/Sources/Entities/UserProfile.swift @@ -0,0 +1,26 @@ +/// `GET /members/me` 결과 — 서버에 저장된 내 계정 정보. +/// 좌표는 서버가 lat·lon을 모두 줄 때만 성립한다(레거시 toEntity 규칙 계승). +public struct UserProfile: Sendable, Equatable { + public let userID: Int? + public let providerID: String? + public let nickname: String? + public let address: String? + public let coordinate: Coordinate? + public let appVersion: String? + + public init( + userID: Int?, + providerID: String?, + nickname: String?, + address: String?, + coordinate: Coordinate?, + appVersion: String? + ) { + self.userID = userID + self.providerID = providerID + self.nickname = nickname + self.address = address + self.coordinate = coordinate + self.appVersion = appVersion + } +} diff --git a/Projects/Domain/Sources/Interfaces/AuthRepository.swift b/Projects/Domain/Sources/Interfaces/AuthRepository.swift index 21d42f6e..8edf532d 100644 --- a/Projects/Domain/Sources/Interfaces/AuthRepository.swift +++ b/Projects/Domain/Sources/Interfaces/AuthRepository.swift @@ -1,4 +1,4 @@ -/// 로그인 도메인 API(`/auth/check`·`/auth/login`) — 구현은 AtchaData. +/// 로그인 도메인 API(`/auth/check`·`/auth/login`·`/auth/sign-up`) — 구현은 AtchaData. /// 소셜 자격 증명을 Bearer로 보내는 특수 계약이라 서버 토큰 데코레이터를 타지 않는다 /// (조합 루트가 plain client를 주입). public protocol AuthRepository: Sendable { @@ -6,4 +6,6 @@ public protocol AuthRepository: Sendable { func checkRegistration(credential: SocialCredential) async throws -> Bool /// GET /auth/login — 소셜 자격 증명을 서버 토큰 쌍으로 교환. func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession + /// POST /auth/sign-up — 최소 가입 후 서버 토큰 쌍 수령(레거시 실측 계약). + func signUp(credential: SocialCredential, form: SignUpForm, fcmToken: String?) async throws -> LoginSession } diff --git a/Projects/Domain/Sources/Interfaces/SessionEnding.swift b/Projects/Domain/Sources/Interfaces/SessionEnding.swift new file mode 100644 index 00000000..642d2ca0 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/SessionEnding.swift @@ -0,0 +1,8 @@ +/// 세션 종료 포트 — 어댑터는 App에서 `AuthSessionManager`로 배선. +public protocol SessionEnding: Sendable { + /// 로그아웃: 서버 `/auth/logout`은 베스트 에포트(실패 무시)로 호출하고, + /// 로컬 토큰 정리와 세션 만료 라우팅(로그인 복귀)을 항상 진행한다. + func endSession() async + /// 탈퇴 후 정리: 서버 토큰은 이미 무효이므로 로컬 정리 + 만료 라우팅만. + func invalidateLocalSession() async +} diff --git a/Projects/Domain/Sources/Interfaces/UserRepository.swift b/Projects/Domain/Sources/Interfaces/UserRepository.swift new file mode 100644 index 00000000..7c455812 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/UserRepository.swift @@ -0,0 +1,11 @@ +/// 계정 도메인 API(`/members/me` 계열) — 구현은 AtchaData, 서버 토큰 데코레이터 적용 대상. +public protocol UserRepository: Sendable { + /// GET /members/me — 내 계정 정보 조회. + func fetchMe() async throws -> UserProfile + /// PATCH /members/me/home-address — 집 주소 변경. + func updateHomeAddress(address: String?, coordinate: Coordinate?) async throws + /// PATCH /members/me/alert-frequency — 알림 빈도 변경. + func updateAlertFrequencies(_ frequencies: [Int]) async throws + /// DELETE /members/me — 회원탈퇴. 성공하면 서버 세션은 이미 무효다. + func withdraw(reason: String?) async throws +} diff --git a/Projects/Domain/Sources/UseCases/GetUserProfileUseCase.swift b/Projects/Domain/Sources/UseCases/GetUserProfileUseCase.swift new file mode 100644 index 00000000..5a50a617 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/GetUserProfileUseCase.swift @@ -0,0 +1,15 @@ +public protocol GetUserProfileUseCase: Sendable { + func execute() async throws -> UserProfile +} + +public struct DefaultGetUserProfileUseCase: GetUserProfileUseCase { + private let userRepository: any UserRepository + + public init(userRepository: any UserRepository) { + self.userRepository = userRepository + } + + public func execute() async throws -> UserProfile { + try await userRepository.fetchMe() + } +} diff --git a/Projects/Domain/Sources/UseCases/LogoutUseCase.swift b/Projects/Domain/Sources/UseCases/LogoutUseCase.swift new file mode 100644 index 00000000..574bfeb6 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/LogoutUseCase.swift @@ -0,0 +1,16 @@ +public protocol LogoutUseCase: Sendable { + /// 서버 로그아웃은 베스트 에포트 — 실패해도 로컬 세션은 끝난다(throws 없음). + func execute() async +} + +public struct DefaultLogoutUseCase: LogoutUseCase { + private let sessionEnding: any SessionEnding + + public init(sessionEnding: any SessionEnding) { + self.sessionEnding = sessionEnding + } + + public func execute() async { + await sessionEnding.endSession() + } +} diff --git a/Projects/Domain/Sources/UseCases/SignInUseCase.swift b/Projects/Domain/Sources/UseCases/SignInUseCase.swift index dd11ab2e..d56af840 100644 --- a/Projects/Domain/Sources/UseCases/SignInUseCase.swift +++ b/Projects/Domain/Sources/UseCases/SignInUseCase.swift @@ -1,14 +1,8 @@ -public enum SignInOutcome: Sendable, Equatable { - /// 서버 토큰 채택까지 완료 — 홈 진입 가능. - case success - /// 미가입 소셜 계정 — 가입 플로우는 후속 Phase, 화면은 안내만 띄운다. - case needsSignUp -} - -/// 소셜 로그인 전 과정: 소셜 인가 → 가입 여부 확인 → (기존 회원이면) 토큰 교환·세션 채택. +/// 소셜 로그인 전 과정: 소셜 인가 → 가입 여부 확인 → 토큰 교환(기존 회원) 또는 +/// 최소 가입(신규 — 온보딩 화면 없이 현재 위치 기반 폼) → 세션 채택. /// 소셜 UI를 띄우는 authorize가 메인 액터 격리라 execute도 @MainActor. public protocol SignInUseCase: Sendable { - @MainActor func execute(provider: SocialLoginProvider) async throws -> SignInOutcome + @MainActor func execute(provider: SocialLoginProvider) async throws } public final class DefaultSignInUseCase: SignInUseCase { @@ -16,35 +10,77 @@ public final class DefaultSignInUseCase: SignInUseCase { private let authRepository: any AuthRepository private let sessionStore: any SessionStoring private let pushTokenProvider: any PushTokenProviding + private let getCurrentLocationUseCase: any GetCurrentLocationUseCase + private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let locationTimeout: Duration public init( socialLoginService: any SocialLoginService, authRepository: any AuthRepository, sessionStore: any SessionStoring, - pushTokenProvider: any PushTokenProviding + pushTokenProvider: any PushTokenProviding, + getCurrentLocationUseCase: any GetCurrentLocationUseCase, + reverseGeocodeUseCase: any ReverseGeocodeUseCase, + locationTimeout: Duration = .seconds(8) ) { self.socialLoginService = socialLoginService self.authRepository = authRepository self.sessionStore = sessionStore self.pushTokenProvider = pushTokenProvider + self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.locationTimeout = locationTimeout } @MainActor - public func execute(provider: SocialLoginProvider) async throws -> SignInOutcome { + public func execute(provider: SocialLoginProvider) async throws { let credential = try await socialLoginService.authorize(provider: provider) - guard try await authRepository.checkRegistration(credential: credential) else { - // 미가입 시 소셜 자격 증명을 보관하지 않는다 — 가입 플로우가 없는 현 - // 스코프에서 평문 보관은 위험만 남긴다(레거시의 UserDefaults 보관 미계승). - return .needsSignUp + let fcmToken = await pushTokenProvider.currentPushToken() + let session: LoginSession + if try await authRepository.checkRegistration(credential: credential) { + session = try await authRepository.login(credential: credential, fcmToken: fcmToken) + } else { + session = try await authRepository.signUp( + credential: credential, + form: await makeMinimalForm(), + fcmToken: fcmToken + ) } - let session = try await authRepository.login( - credential: credential, - fcmToken: await pushTokenProvider.currentPushToken() - ) try await sessionStore.store( accessToken: session.accessToken, refreshToken: session.refreshToken ) - return .success + } + + /// 최소 가입 폼 — 위치 실패(권한 거부·타임아웃)면 레거시 실측 폴백, + /// 역지오코딩만 실패하면 주소만 빈 문자열로 강등한다. + private func makeMinimalForm() async -> SignUpForm { + guard let coordinate = await currentCoordinate() else { + return .minimalFallback + } + let address = (try? await reverseGeocodeUseCase.execute(coordinate: coordinate))?.address ?? "" + return SignUpForm( + userName: "", + address: address, + coordinate: coordinate, + alertFrequencies: SignUpForm.defaultAlertFrequencies + ) + } + + /// 위치 조회는 권한 프롬프트 대기 등으로 길어질 수 있어 타임아웃과 레이스한다 — + /// 가입이 위치 때문에 무한정 막히면 안 된다. + private func currentCoordinate() async -> Coordinate? { + let locationUseCase = getCurrentLocationUseCase + let timeout = locationTimeout + return await withTaskGroup(of: Coordinate?.self) { group in + group.addTask { try? await locationUseCase.execute() } + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } } } diff --git a/Projects/Domain/Sources/UseCases/UpdateAlertFrequencyUseCase.swift b/Projects/Domain/Sources/UseCases/UpdateAlertFrequencyUseCase.swift new file mode 100644 index 00000000..d7e8c713 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/UpdateAlertFrequencyUseCase.swift @@ -0,0 +1,15 @@ +public protocol UpdateAlertFrequencyUseCase: Sendable { + func execute(frequencies: [Int]) async throws +} + +public struct DefaultUpdateAlertFrequencyUseCase: UpdateAlertFrequencyUseCase { + private let userRepository: any UserRepository + + public init(userRepository: any UserRepository) { + self.userRepository = userRepository + } + + public func execute(frequencies: [Int]) async throws { + try await userRepository.updateAlertFrequencies(frequencies) + } +} diff --git a/Projects/Domain/Sources/UseCases/UpdateHomeAddressUseCase.swift b/Projects/Domain/Sources/UseCases/UpdateHomeAddressUseCase.swift new file mode 100644 index 00000000..8773f2f9 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/UpdateHomeAddressUseCase.swift @@ -0,0 +1,15 @@ +public protocol UpdateHomeAddressUseCase: Sendable { + func execute(address: String?, coordinate: Coordinate?) async throws +} + +public struct DefaultUpdateHomeAddressUseCase: UpdateHomeAddressUseCase { + private let userRepository: any UserRepository + + public init(userRepository: any UserRepository) { + self.userRepository = userRepository + } + + public func execute(address: String?, coordinate: Coordinate?) async throws { + try await userRepository.updateHomeAddress(address: address, coordinate: coordinate) + } +} diff --git a/Projects/Domain/Sources/UseCases/WithdrawUseCase.swift b/Projects/Domain/Sources/UseCases/WithdrawUseCase.swift new file mode 100644 index 00000000..62efa5d9 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/WithdrawUseCase.swift @@ -0,0 +1,20 @@ +public protocol WithdrawUseCase: Sendable { + func execute(reason: String?) async throws +} + +public struct DefaultWithdrawUseCase: WithdrawUseCase { + private let userRepository: any UserRepository + private let sessionEnding: any SessionEnding + + public init(userRepository: any UserRepository, sessionEnding: any SessionEnding) { + self.userRepository = userRepository + self.sessionEnding = sessionEnding + } + + public func execute(reason: String?) async throws { + // 탈퇴가 서버에서 성공한 뒤에만 로컬 세션을 무효화한다 — + // 실패 시 세션을 보존해야 사용자가 재시도할 수 있다. + try await userRepository.withdraw(reason: reason) + await sessionEnding.invalidateLocalSession() + } +} diff --git a/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift b/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift new file mode 100644 index 00000000..4f03c7c3 --- /dev/null +++ b/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift @@ -0,0 +1,23 @@ +@testable import Domain +import Synchronization +import Testing + +private final class SessionEndingSpy: SessionEnding, Sendable { + private let events = Mutex<[String]>([]) + var log: [String] { events.withLock { $0 } } + + func endSession() async { events.withLock { $0.append("endSession") } } + func invalidateLocalSession() async { events.withLock { $0.append("invalidate") } } +} + +struct DefaultLogoutUseCaseTests { + @Test + func execute_endsSession() async { + let spy = SessionEndingSpy() + let sut = DefaultLogoutUseCase(sessionEnding: spy) + + await sut.execute() + + #expect(spy.log == ["endSession"]) + } +} diff --git a/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift b/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift index 60ac3235..b6fb998a 100644 --- a/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift @@ -27,6 +27,7 @@ private struct StubAuthRepository: AuthRepository { let recorder: SignInRecorder var exists = true var session = LoginSession(userID: 1, accessToken: "SA", refreshToken: "SR") + var signUpError: (any Error)? func checkRegistration(credential: SocialCredential) async throws -> Bool { recorder.record("check(\(credential.accessToken))") @@ -37,6 +38,20 @@ private struct StubAuthRepository: AuthRepository { recorder.record("login(fcm:\(fcmToken ?? "nil"))") return session } + + func signUp( + credential: SocialCredential, + form: SignUpForm, + fcmToken: String? + ) async throws -> LoginSession { + recorder.record( + "signUp(addr:\(form.address)/lat:\(form.coordinate.latitude)" + + "/freq:\(form.alertFrequencies)/name:\(form.userName ?? "nil")" + + "/fcm:\(fcmToken ?? "nil"))" + ) + if let signUpError { throw signUpError } + return session + } } private struct StubSessionStore: SessionStoring { @@ -55,7 +70,28 @@ private struct StubPushTokenProvider: PushTokenProviding { func currentPushToken() async -> String? { token } } +private struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { + var result: Result = + .success(Coordinate(latitude: 37.5, longitude: 127.0)) + + func execute() async throws -> Coordinate { + try result.get() + } +} + +private struct StubReverseGeocodeUseCase: ReverseGeocodeUseCase { + var result: Result = .success( + Place(name: "집앞", address: "서울 어딘가 1-2", coordinate: Coordinate(latitude: 37.5, longitude: 127.0)) + ) + + func execute(coordinate: Coordinate) async throws -> Place { + try result.get() + } +} + private struct StoreError: Error {} +private struct SignUpError: Error {} +private struct GeocodeError: Error {} struct DefaultSignInUseCaseTests { private let recorder = SignInRecorder() @@ -64,13 +100,17 @@ struct DefaultSignInUseCaseTests { social: StubSocialLoginService? = nil, repository: StubAuthRepository? = nil, store: StubSessionStore? = nil, - pushToken: String? = nil + pushToken: String? = nil, + location: StubGetCurrentLocationUseCase = StubGetCurrentLocationUseCase(), + geocode: StubReverseGeocodeUseCase = StubReverseGeocodeUseCase() ) -> DefaultSignInUseCase { DefaultSignInUseCase( socialLoginService: social ?? StubSocialLoginService(recorder: recorder), authRepository: repository ?? StubAuthRepository(recorder: recorder), sessionStore: store ?? StubSessionStore(recorder: recorder), - pushTokenProvider: StubPushTokenProvider(token: pushToken) + pushTokenProvider: StubPushTokenProvider(token: pushToken), + getCurrentLocationUseCase: location, + reverseGeocodeUseCase: geocode ) } @@ -78,9 +118,8 @@ struct DefaultSignInUseCaseTests { func execute_registered_runsAuthorizeCheckLoginStoreInOrder() async throws { let sut = makeSUT(pushToken: "FCM") - let outcome = try await sut.execute(provider: .kakao) + try await sut.execute(provider: .kakao) - #expect(outcome == .success) #expect(recorder.log == [ "authorize", "check(SOCIAL)", @@ -93,19 +132,69 @@ struct DefaultSignInUseCaseTests { func execute_noPushToken_passesNilToLogin() async throws { let sut = makeSUT() - _ = try await sut.execute(provider: .apple) + try await sut.execute(provider: .apple) #expect(recorder.count(of: "login(fcm:nil)") == 1) } + /// 미가입이면 자동으로 최소 가입한다 — 현재 좌표 + 역지오코딩 주소 + 기본 알림 빈도. + @Test + func execute_notRegistered_signsUpWithLocationFormThenStores() async throws { + let sut = makeSUT( + repository: StubAuthRepository(recorder: recorder, exists: false), + pushToken: "FCM" + ) + + try await sut.execute(provider: .kakao) + + #expect(recorder.log == [ + "authorize", + "check(SOCIAL)", + "signUp(addr:서울 어딘가 1-2/lat:37.5/freq:[1, 10]/name:/fcm:FCM)", + "store(SA/SR)", + ]) + } + + /// 위치 실패(권한 거부 등) → 레거시 실측 폴백(address ""·좌표 (0,0))으로 가입한다. @Test - func execute_notRegistered_returnsNeedsSignUpWithoutLoginOrStore() async throws { - let sut = makeSUT(repository: StubAuthRepository(recorder: recorder, exists: false)) + func execute_notRegistered_locationFailure_fallsBackToMinimalForm() async throws { + let sut = makeSUT( + repository: StubAuthRepository(recorder: recorder, exists: false), + location: StubGetCurrentLocationUseCase(result: .failure(.permissionDenied)) + ) + + try await sut.execute(provider: .kakao) - let outcome = try await sut.execute(provider: .kakao) + #expect(recorder.count(of: "signUp(addr:/lat:0.0/freq:[1, 10]/name:/fcm:nil)") == 1) + } + + /// 역지오코딩만 실패하면 좌표는 유지하고 주소만 빈 문자열로 강등한다. + @Test + func execute_notRegistered_geocodeFailure_keepsCoordinateWithEmptyAddress() async throws { + let sut = makeSUT( + repository: StubAuthRepository(recorder: recorder, exists: false), + geocode: StubReverseGeocodeUseCase(result: .failure(GeocodeError())) + ) + + try await sut.execute(provider: .kakao) + + #expect(recorder.count(of: "signUp(addr:/lat:37.5/freq:[1, 10]/name:/fcm:nil)") == 1) + } + + /// 가입 실패는 그대로 전파되고 세션은 채택되지 않는다. + @Test + func execute_signUpFailure_propagatesWithoutStore() async { + let sut = makeSUT( + repository: StubAuthRepository( + recorder: recorder, exists: false, signUpError: SignUpError() + ) + ) + + await #expect(throws: SignUpError.self) { + try await sut.execute(provider: .kakao) + } - #expect(outcome == .needsSignUp) - #expect(recorder.log == ["authorize", "check(SOCIAL)"]) + #expect(recorder.count(of: "store(SA/SR)") == 0) } @Test diff --git a/Projects/Domain/Tests/DefaultWithdrawUseCaseTests.swift b/Projects/Domain/Tests/DefaultWithdrawUseCaseTests.swift new file mode 100644 index 00000000..230b9ab0 --- /dev/null +++ b/Projects/Domain/Tests/DefaultWithdrawUseCaseTests.swift @@ -0,0 +1,74 @@ +@testable import Domain +import Synchronization +import Testing + +private final class WithdrawRecorder: Sendable { + private let events = Mutex<[String]>([]) + + func record(_ event: String) { events.withLock { $0.append(event) } } + var log: [String] { events.withLock { $0 } } +} + +private struct StubUserRepository: UserRepository { + let recorder: WithdrawRecorder + var withdrawError: (any Error)? + + func fetchMe() async throws -> UserProfile { + UserProfile( + userID: nil, providerID: nil, nickname: nil, + address: nil, coordinate: nil, appVersion: nil + ) + } + + func updateHomeAddress(address: String?, coordinate: Coordinate?) async throws {} + + func updateAlertFrequencies(_ frequencies: [Int]) async throws {} + + func withdraw(reason: String?) async throws { + recorder.record("withdraw(\(reason ?? "nil"))") + if let withdrawError { throw withdrawError } + } +} + +private struct StubSessionEnding: SessionEnding { + let recorder: WithdrawRecorder + + func endSession() async { recorder.record("endSession") } + func invalidateLocalSession() async { recorder.record("invalidate") } +} + +private struct WithdrawServerError: Error {} + +struct DefaultWithdrawUseCaseTests { + private let recorder = WithdrawRecorder() + + /// 탈퇴가 서버에서 성공한 뒤에만 로컬 세션을 무효화한다. + @Test + func execute_success_withdrawsThenInvalidatesLocalSession() async throws { + let sut = DefaultWithdrawUseCase( + userRepository: StubUserRepository(recorder: recorder), + sessionEnding: StubSessionEnding(recorder: recorder) + ) + + try await sut.execute(reason: "서비스 미사용") + + #expect(recorder.log == ["withdraw(서비스 미사용)", "invalidate"]) + } + + /// 서버 탈퇴 실패 시 세션을 보존한다 — 재시도할 수 있어야 한다. + @Test + func execute_serverFailure_propagatesAndKeepsSession() async { + let sut = DefaultWithdrawUseCase( + userRepository: StubUserRepository( + recorder: recorder, withdrawError: WithdrawServerError() + ), + sessionEnding: StubSessionEnding(recorder: recorder) + ) + + await #expect(throws: WithdrawServerError.self) { + try await sut.execute(reason: nil) + } + + #expect(recorder.log == ["withdraw(nil)"]) + } +} From 87202de968cd7f7a0ddc402489ea2774fb5efef0 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 13 Sep 2026 16:41:46 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20CoreAuth=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=E2=80=94=20refresh=20Bearer=20=ED=8A=B9?= =?UTF-8?q?=EB=A1=80=EC=9D=98=20LogoutEndpoint=C2=B7signOut/invalidateSess?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LogoutEndpoint: POST /auth/logout, refresh 토큰을 Bearer로(레거시 TokenInterceptor 특례 실측) — 토큰이 CoreAuth 밖으로 새지 않도록 여기 배치 - AuthSessionManager.signOut(): 서버 호출은 베스트 에포트(실패 무시), 로컬 정리+sessionExpired yield는 항상 — 기존 만료 라우팅이 로그인 복귀 처리 - invalidateSession(): 탈퇴 후 경로 — 네트워크 호출 없이 로컬 만료만 - 계약 핀·베스트 에포트·무토큰 스킵 테스트 4건 추가 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VEZMsiwwexokUxJBEk2C1A --- .../Auth/Sources/AuthSessionManager.swift | 16 +++++ .../Core/Auth/Sources/LogoutEndpoint.swift | 12 ++++ .../Auth/Tests/AuthSessionManagerTests.swift | 61 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 Projects/Core/Auth/Sources/LogoutEndpoint.swift diff --git a/Projects/Core/Auth/Sources/AuthSessionManager.swift b/Projects/Core/Auth/Sources/AuthSessionManager.swift index 123863fe..d5e5d42c 100644 --- a/Projects/Core/Auth/Sources/AuthSessionManager.swift +++ b/Projects/Core/Auth/Sources/AuthSessionManager.swift @@ -54,6 +54,22 @@ public actor AuthSessionManager { try tokenStore.clear() } + /// Logout: best-effort server call (legacy special case — the *refresh* + /// token rides as Bearer), then local expiry either way. Offline or a + /// server failure must never trap the user in a session they asked to end. + public func signOut() async { + if let refreshToken = try? tokenStore.refreshToken() { + _ = try? await networkClient.data(for: LogoutEndpoint(refreshToken: refreshToken)) + } + _ = expireSession() + } + + /// Post-withdrawal cleanup: the server already revoked the tokens, so no + /// network call — just local expiry and the login-routing yield. + public func invalidateSession() { + _ = expireSession() + } + /// Single entry point for 401 recovery: refresh, or declare the session /// dead (`AuthError.loginRequired` + a `sessionExpired` yield). Concurrent /// callers join the in-flight recovery instead of starting another. diff --git a/Projects/Core/Auth/Sources/LogoutEndpoint.swift b/Projects/Core/Auth/Sources/LogoutEndpoint.swift new file mode 100644 index 00000000..5f221e55 --- /dev/null +++ b/Projects/Core/Auth/Sources/LogoutEndpoint.swift @@ -0,0 +1,12 @@ +import CoreNetwork + +/// Legacy-measured contract (TokenInterceptor special case): POST /auth/logout +/// with the *refresh* token as the Bearer credential — the reason this endpoint +/// lives in CoreAuth instead of AtchaData, which has no token access. +struct LogoutEndpoint: Endpoint { + let refreshToken: String + + var path: String { "/auth/logout" } + var method: HTTPMethod { .post } + var headers: [String: String] { ["Authorization": "Bearer \(refreshToken)"] } +} diff --git a/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift index f59f2095..d58397c7 100644 --- a/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift +++ b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift @@ -56,6 +56,67 @@ struct AuthSessionManagerTests { #expect(manager.bootstrapState() == .loginRequired) } + // MARK: - signOut / invalidateSession + + /// Pins the legacy-measured logout contract: POST /auth/logout with the + /// *refresh* token as the Bearer credential. + @Test + func signOut_sendsPostLogoutWithBearerRefreshHeader_thenClearsAndYields() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, network) = makeManager() + + await manager.signOut() + + let logouts = network.recordedCalls(to: "/auth/logout") + #expect(logouts.count == 1) + #expect(logouts.first?.method == .post) + #expect(logouts.first?.headers["Authorization"] == "Bearer R") + #expect(try tokenStore.accessToken() == nil) + #expect(try tokenStore.refreshToken() == nil) + + var iterator = manager.sessionExpired.makeAsyncIterator() + #expect(await iterator.next() != nil) + } + + /// Best-effort: a server failure must not trap the user in the session. + @Test + func signOut_serverFailure_stillClearsTokensAndYields() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _) = makeManager(handler: { _, _ in + throw NetworkError.offline(underlying: URLError(.notConnectedToInternet)) + }) + + await manager.signOut() + + #expect(try tokenStore.accessToken() == nil) + var iterator = manager.sessionExpired.makeAsyncIterator() + #expect(await iterator.next() != nil) + } + + /// No refresh token → nothing to revoke server-side; local expiry only. + @Test + func signOut_withoutRefreshToken_skipsServerCall() async throws { + let (manager, network) = makeManager() + + await manager.signOut() + + #expect(network.recorded.isEmpty) + } + + /// Post-withdrawal path: no network call, local expiry + yield only. + @Test + func invalidateSession_clearsTokensAndYieldsWithoutNetwork() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, network) = makeManager() + + await manager.invalidateSession() + + #expect(network.recorded.isEmpty) + #expect(try tokenStore.accessToken() == nil) + var iterator = manager.sessionExpired.makeAsyncIterator() + #expect(await iterator.next() != nil) + } + // MARK: - recoverSession /// Pins the legacy-measured reissue contract: GET /auth/reissue with the From 1d9c59c8020407dc17412406c6a4ed9a461a2ba7 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 13 Sep 2026 16:41:58 +0900 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20AtchaData=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?API=20=E2=80=94=20sign-up=C2=B7UserEndpoint=C2=B7DTO=C2=B7UserR?= =?UTF-8?q?epositoryImpl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthEndpoint.signUp: POST /auth/sign-up, 소셜 토큰 Bearer + JSON body (레거시 실측), 응답은 LoginResponseDTO 재사용(로그인과 계약 동일) - UserEndpoint: GET /members/me(X-Platform: iOS)·PATCH home-address· PATCH alert-frequency·DELETE /members/me(사유 body) - DTO 6종(SignUpRequest·UserInfoResponse·UserInfoPatchResponse· HomePatch·AlertFrequencyPatch·Withdraw) — 레거시 필드 실측 그대로 - UserRepositoryImpl(authorized client)·AuthRepositoryImpl.signUp(plain 규약 유지) - 계약 핀 테스트: signUp body·X-Platform·PATCH/DELETE body·lat/lon 키·좌표 nil 규칙 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VEZMsiwwexokUxJBEk2C1A --- .../DTO/AlertFrequencyPatchRequestDTO.swift | 4 ++ .../Sources/DTO/HomePatchRequestDTO.swift | 6 ++ .../Data/Sources/DTO/SignUpRequestDTO.swift | 12 ++++ .../DTO/UserInfoPatchResponseDTO.swift | 9 +++ .../Sources/DTO/UserInfoResponseDTO.swift | 30 +++++++++ .../Data/Sources/DTO/WithdrawRequestDTO.swift | 4 ++ .../Data/Sources/Network/AuthEndpoint.swift | 16 +++++ .../Data/Sources/Network/UserEndpoint.swift | 44 +++++++++++++ .../Repositories/AuthRepositoryImpl.swift | 22 +++++++ .../Repositories/UserRepositoryImpl.swift | 41 ++++++++++++ Projects/Data/Tests/AuthEndpointTests.swift | 35 +++++++++++ Projects/Data/Tests/UserEndpointTests.swift | 63 +++++++++++++++++++ .../Data/Tests/UserInfoResponseDTOTests.swift | 56 +++++++++++++++++ 13 files changed, 342 insertions(+) create mode 100644 Projects/Data/Sources/DTO/AlertFrequencyPatchRequestDTO.swift create mode 100644 Projects/Data/Sources/DTO/HomePatchRequestDTO.swift create mode 100644 Projects/Data/Sources/DTO/SignUpRequestDTO.swift create mode 100644 Projects/Data/Sources/DTO/UserInfoPatchResponseDTO.swift create mode 100644 Projects/Data/Sources/DTO/UserInfoResponseDTO.swift create mode 100644 Projects/Data/Sources/DTO/WithdrawRequestDTO.swift create mode 100644 Projects/Data/Sources/Network/UserEndpoint.swift create mode 100644 Projects/Data/Sources/Repositories/UserRepositoryImpl.swift create mode 100644 Projects/Data/Tests/UserEndpointTests.swift create mode 100644 Projects/Data/Tests/UserInfoResponseDTOTests.swift diff --git a/Projects/Data/Sources/DTO/AlertFrequencyPatchRequestDTO.swift b/Projects/Data/Sources/DTO/AlertFrequencyPatchRequestDTO.swift new file mode 100644 index 00000000..5a40a5e5 --- /dev/null +++ b/Projects/Data/Sources/DTO/AlertFrequencyPatchRequestDTO.swift @@ -0,0 +1,4 @@ +/// `PATCH /members/me/alert-frequency` body(레거시 PushAlarmPatchRequest 실측). +struct AlertFrequencyPatchRequestDTO: Encodable, Sendable { + let alertFrequencies: [Int]? +} diff --git a/Projects/Data/Sources/DTO/HomePatchRequestDTO.swift b/Projects/Data/Sources/DTO/HomePatchRequestDTO.swift new file mode 100644 index 00000000..77eb1a25 --- /dev/null +++ b/Projects/Data/Sources/DTO/HomePatchRequestDTO.swift @@ -0,0 +1,6 @@ +/// `PATCH /members/me/home-address` body(레거시 HomePatchRequest 실측). +struct HomePatchRequestDTO: Encodable, Sendable { + let address: String? + let lat: Double? + let lon: Double? +} diff --git a/Projects/Data/Sources/DTO/SignUpRequestDTO.swift b/Projects/Data/Sources/DTO/SignUpRequestDTO.swift new file mode 100644 index 00000000..8ba37845 --- /dev/null +++ b/Projects/Data/Sources/DTO/SignUpRequestDTO.swift @@ -0,0 +1,12 @@ +/// `POST /auth/sign-up` body(레거시 OnboardingDTO.SignUpRequest 실측). +/// 응답은 `/auth/login`과 계약이 같아 `LoginResponseDTO`를 재사용한다. +struct SignUpRequestDTO: Encodable, Sendable { + let provider: Int + let userName: String? + let address: String + let lat: Double + let lon: Double + let alertFrequencies: [Int] + /// 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. + let fcmToken: String +} diff --git a/Projects/Data/Sources/DTO/UserInfoPatchResponseDTO.swift b/Projects/Data/Sources/DTO/UserInfoPatchResponseDTO.swift new file mode 100644 index 00000000..6dc8426b --- /dev/null +++ b/Projects/Data/Sources/DTO/UserInfoPatchResponseDTO.swift @@ -0,0 +1,9 @@ +/// 두 PATCH(`home-address`·`alert-frequency`) 공용 응답(레거시 UserInfoPatchResponse +/// 실측) — 전 필드 옵셔널. V2에 소비처가 없어 디코딩 검증 후 폐기한다. +struct UserInfoPatchResponseDTO: Decodable, Sendable { + let id: Int? + let providerId: String? + let address: String? + let lat: Double? + let lon: Double? +} diff --git a/Projects/Data/Sources/DTO/UserInfoResponseDTO.swift b/Projects/Data/Sources/DTO/UserInfoResponseDTO.swift new file mode 100644 index 00000000..d768d6b6 --- /dev/null +++ b/Projects/Data/Sources/DTO/UserInfoResponseDTO.swift @@ -0,0 +1,30 @@ +import Domain + +/// `GET /members/me` 응답(레거시 UserInfoResponse 실측) — 좌표 키는 "lat"/"lon", +/// appVersion만 비옵셔널. profileUrl은 V2 미사용이라 디코딩만 하고 버린다. +struct UserInfoResponseDTO: Decodable, Sendable { + let id: Int? + let providerId: String? + let nickname: String? + let profileUrl: String? + let address: String? + let lat: Double? + let lon: Double? + let appVersion: String + + func toEntity() -> UserProfile { + let coordinate: Coordinate? = if let lat, let lon { + Coordinate(latitude: lat, longitude: lon) + } else { + nil + } + return UserProfile( + userID: id, + providerID: providerId, + nickname: nickname, + address: address, + coordinate: coordinate, + appVersion: appVersion + ) + } +} diff --git a/Projects/Data/Sources/DTO/WithdrawRequestDTO.swift b/Projects/Data/Sources/DTO/WithdrawRequestDTO.swift new file mode 100644 index 00000000..5e3a2577 --- /dev/null +++ b/Projects/Data/Sources/DTO/WithdrawRequestDTO.swift @@ -0,0 +1,4 @@ +/// `DELETE /members/me` body(레거시 WithdrawRequest 실측). +struct WithdrawRequestDTO: Encodable, Sendable { + let reason: String? +} diff --git a/Projects/Data/Sources/Network/AuthEndpoint.swift b/Projects/Data/Sources/Network/AuthEndpoint.swift index b848447f..b13d7e6c 100644 --- a/Projects/Data/Sources/Network/AuthEndpoint.swift +++ b/Projects/Data/Sources/Network/AuthEndpoint.swift @@ -8,17 +8,20 @@ import Foundation enum AuthEndpoint: Endpoint { case check(SocialCredential) case login(SocialCredential, fcmToken: String?) + case signUp(SocialCredential, SignUpRequestDTO) var path: String { switch self { case .check: "/auth/check" case .login: "/auth/login" + case .signUp: "/auth/sign-up" } } var method: HTTPMethod { switch self { case .check, .login: .get + case .signUp: .post } } @@ -26,6 +29,11 @@ enum AuthEndpoint: Endpoint { switch self { case let .check(credential), let .login(credential, _): ["Authorization": "Bearer \(credential.accessToken)"] + case let .signUp(credential, _): + [ + "Authorization": "Bearer \(credential.accessToken)", + "Content-Type": "application/json", + ] } } @@ -39,6 +47,14 @@ enum AuthEndpoint: Endpoint { // 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. URLQueryItem(name: "fcmToken", value: fcmToken ?? ""), ] + case .signUp: [] + } + } + + var body: Data? { + switch self { + case .check, .login: nil + case let .signUp(_, request): try? JSONEncoder().encode(request) } } } diff --git a/Projects/Data/Sources/Network/UserEndpoint.swift b/Projects/Data/Sources/Network/UserEndpoint.swift new file mode 100644 index 00000000..39c1c7ac --- /dev/null +++ b/Projects/Data/Sources/Network/UserEndpoint.swift @@ -0,0 +1,44 @@ +import CoreNetwork +import Foundation + +enum UserEndpoint: Endpoint { + case me + case updateHomeAddress(HomePatchRequestDTO) + case updateAlertFrequencies(AlertFrequencyPatchRequestDTO) + // 탈퇴는 DELETE지만 body로 사유를 받는다 (레거시 실측). + case withdraw(WithdrawRequestDTO) + + var path: String { + switch self { + case .me, .withdraw: "/members/me" + case .updateHomeAddress: "/members/me/home-address" + case .updateAlertFrequencies: "/members/me/alert-frequency" + } + } + + var method: HTTPMethod { + switch self { + case .me: .get + case .updateHomeAddress, .updateAlertFrequencies: .patch + case .withdraw: .delete + } + } + + var headers: [String: String] { + switch self { + // 레거시 실측: 조회에만 플랫폼 식별 헤더가 붙는다. + case .me: ["X-Platform": "iOS"] + case .updateHomeAddress, .updateAlertFrequencies, .withdraw: + ["Content-Type": "application/json"] + } + } + + var body: Data? { + switch self { + case .me: nil + case let .updateHomeAddress(request): try? JSONEncoder().encode(request) + case let .updateAlertFrequencies(request): try? JSONEncoder().encode(request) + case let .withdraw(request): try? JSONEncoder().encode(request) + } + } +} diff --git a/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift index c194bbba..486c7c7d 100644 --- a/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift +++ b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift @@ -23,4 +23,26 @@ public struct AuthRepositoryImpl: AuthRepository { ) return try dto.toEntity() } + + public func signUp( + credential: SocialCredential, + form: SignUpForm, + fcmToken: String? + ) async throws -> LoginSession { + let request = SignUpRequestDTO( + provider: credential.provider.serverCode, + userName: form.userName, + address: form.address, + lat: form.coordinate.latitude, + lon: form.coordinate.longitude, + alertFrequencies: form.alertFrequencies, + // 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. + fcmToken: fcmToken ?? "" + ) + // 응답 계약은 /auth/login과 동일 — LoginResponseDTO 재사용. + let dto: LoginResponseDTO = try await networkClient.requestEnveloped( + AuthEndpoint.signUp(credential, request) + ) + return try dto.toEntity() + } } diff --git a/Projects/Data/Sources/Repositories/UserRepositoryImpl.swift b/Projects/Data/Sources/Repositories/UserRepositoryImpl.swift new file mode 100644 index 00000000..15e0b400 --- /dev/null +++ b/Projects/Data/Sources/Repositories/UserRepositoryImpl.swift @@ -0,0 +1,41 @@ +import CoreNetwork +import Domain + +public struct UserRepositoryImpl: UserRepository { + private let networkClient: any NetworkClient + + /// 서버 토큰 데코레이터가 적용된 클라이언트를 주입할 것 — `/members/me` 계열은 + /// access 토큰 Bearer + 401 세션 복구의 일반 규약을 따른다. + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + public func fetchMe() async throws -> UserProfile { + let dto: UserInfoResponseDTO = try await networkClient.requestEnveloped(UserEndpoint.me) + return dto.toEntity() + } + + public func updateHomeAddress(address: String?, coordinate: Coordinate?) async throws { + let _: UserInfoPatchResponseDTO = try await networkClient.requestEnveloped( + UserEndpoint.updateHomeAddress( + HomePatchRequestDTO( + address: address, + lat: coordinate?.latitude, + lon: coordinate?.longitude + ) + ) + ) + } + + public func updateAlertFrequencies(_ frequencies: [Int]) async throws { + let _: UserInfoPatchResponseDTO = try await networkClient.requestEnveloped( + UserEndpoint.updateAlertFrequencies(AlertFrequencyPatchRequestDTO(alertFrequencies: frequencies)) + ) + } + + public func withdraw(reason: String?) async throws { + let _: APIEmptyResult = try await networkClient.requestEnveloped( + UserEndpoint.withdraw(WithdrawRequestDTO(reason: reason)) + ) + } +} diff --git a/Projects/Data/Tests/AuthEndpointTests.swift b/Projects/Data/Tests/AuthEndpointTests.swift index 540f4a54..1d29c359 100644 --- a/Projects/Data/Tests/AuthEndpointTests.swift +++ b/Projects/Data/Tests/AuthEndpointTests.swift @@ -50,4 +50,39 @@ struct AuthEndpointTests { URLQueryItem(name: "fcmToken", value: ""), ]) } + + /// 레거시 실측 계약 핀: sign-up도 소셜 토큰이 Bearer로, 필드는 JSON body로. + @Test + func signUp_sendsSocialBearerAndJSONBody() throws { + let request = SignUpRequestDTO( + provider: 0, + userName: "", + address: "서울 어딘가 1-2", + lat: 37.5, + lon: 127.0, + alertFrequencies: [1, 10], + fcmToken: "" + ) + let endpoint = AuthEndpoint.signUp(kakaoCredential, request) + + #expect(endpoint.path == "/auth/sign-up") + #expect(endpoint.method == .post) + #expect(endpoint.headers == [ + "Authorization": "Bearer KAKAO_AT", + "Content-Type": "application/json", + ]) + #expect(endpoint.queryItems.isEmpty) + + let body = try #require(endpoint.body) + let json = try #require( + try JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + #expect(json["provider"] as? Int == 0) + #expect(json["userName"] as? String == "") + #expect(json["address"] as? String == "서울 어딘가 1-2") + #expect(json["lat"] as? Double == 37.5) + #expect(json["lon"] as? Double == 127.0) + #expect(json["alertFrequencies"] as? [Int] == [1, 10]) + #expect(json["fcmToken"] as? String == "") + } } diff --git a/Projects/Data/Tests/UserEndpointTests.swift b/Projects/Data/Tests/UserEndpointTests.swift new file mode 100644 index 00000000..0d99ca9a --- /dev/null +++ b/Projects/Data/Tests/UserEndpointTests.swift @@ -0,0 +1,63 @@ +@testable import AtchaData +import CoreNetwork +import Foundation +import Testing + +struct UserEndpointTests { + /// 레거시 실측 계약 핀: 조회에만 플랫폼 식별 헤더가 붙는다. + @Test + func me_sendsPlatformHeader() { + let endpoint = UserEndpoint.me + + #expect(endpoint.path == "/members/me") + #expect(endpoint.method == .get) + #expect(endpoint.headers == ["X-Platform": "iOS"]) + #expect(endpoint.body == nil) + } + + @Test + func updateHomeAddress_patchesWithJSONBody() throws { + let endpoint = UserEndpoint.updateHomeAddress( + HomePatchRequestDTO(address: "서울 어딘가 1-2", lat: 37.5, lon: 127.0) + ) + + #expect(endpoint.path == "/members/me/home-address") + #expect(endpoint.method == .patch) + #expect(endpoint.headers == ["Content-Type": "application/json"]) + + let json = try decodeBody(endpoint) + #expect(json["address"] as? String == "서울 어딘가 1-2") + #expect(json["lat"] as? Double == 37.5) + #expect(json["lon"] as? Double == 127.0) + } + + @Test + func updateAlertFrequencies_patchesWithJSONBody() throws { + let endpoint = UserEndpoint.updateAlertFrequencies( + AlertFrequencyPatchRequestDTO(alertFrequencies: [1, 10]) + ) + + #expect(endpoint.path == "/members/me/alert-frequency") + #expect(endpoint.method == .patch) + + let json = try decodeBody(endpoint) + #expect(json["alertFrequencies"] as? [Int] == [1, 10]) + } + + /// 레거시 실측: 탈퇴는 DELETE지만 body로 사유를 싣는다. + @Test + func withdraw_deletesWithReasonBody() throws { + let endpoint = UserEndpoint.withdraw(WithdrawRequestDTO(reason: "서비스 미사용")) + + #expect(endpoint.path == "/members/me") + #expect(endpoint.method == .delete) + + let json = try decodeBody(endpoint) + #expect(json["reason"] as? String == "서비스 미사용") + } + + private func decodeBody(_ endpoint: any Endpoint) throws -> [String: Any] { + let body = try #require(endpoint.body) + return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + } +} diff --git a/Projects/Data/Tests/UserInfoResponseDTOTests.swift b/Projects/Data/Tests/UserInfoResponseDTOTests.swift new file mode 100644 index 00000000..af0ea786 --- /dev/null +++ b/Projects/Data/Tests/UserInfoResponseDTOTests.swift @@ -0,0 +1,56 @@ +@testable import AtchaData +import Foundation +import Testing + +struct UserInfoResponseDTOTests { + /// 레거시 실측 키 핀: 좌표는 "lat"/"lon", appVersion만 비옵셔널. + @Test + func decode_fullPayload_mapsToEntityWithCoordinate() throws { + let json = Data(""" + { + "id": 7, + "providerId": "kakao_123", + "nickname": "곤", + "profileUrl": "https://example.com/p.png", + "address": "서울 어딘가 1-2", + "lat": 37.5, + "lon": 127.0, + "appVersion": "2.0.0" + } + """.utf8) + + let dto = try JSONDecoder().decode(UserInfoResponseDTO.self, from: json) + let entity = dto.toEntity() + + #expect(entity.userID == 7) + #expect(entity.providerID == "kakao_123") + #expect(entity.nickname == "곤") + #expect(entity.address == "서울 어딘가 1-2") + #expect(entity.coordinate?.latitude == 37.5) + #expect(entity.coordinate?.longitude == 127.0) + #expect(entity.appVersion == "2.0.0") + } + + /// 좌표는 lat·lon이 모두 있을 때만 성립한다(레거시 toEntity 규칙). + @Test + func decode_missingLon_dropsCoordinate() throws { + let json = Data(""" + { "lat": 37.5, "appVersion": "2.0.0" } + """.utf8) + + let dto = try JSONDecoder().decode(UserInfoResponseDTO.self, from: json) + + #expect(dto.toEntity().coordinate == nil) + } + + /// 두 PATCH 공용 응답 — 전 필드 옵셔널이라 빈 객체도 디코딩된다. + @Test + func patchResponse_decodesEmptyObject() throws { + let dto = try JSONDecoder().decode( + UserInfoPatchResponseDTO.self, from: Data("{}".utf8) + ) + + #expect(dto.id == nil) + #expect(dto.address == nil) + } +} From ebe48513b685c4e09c7f7cff18722432c22ed942 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 13 Sep 2026 16:42:08 +0900 Subject: [PATCH 12/13] =?UTF-8?q?feat:=20AuthFeature=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=9D=90=EB=A6=84=20=EC=A0=95=EB=A6=AC=20=E2=80=94?= =?UTF-8?q?=20needsSignUp=20=EB=B6=84=EA=B8=B0=C2=B7DEV=20=EA=B1=B4?= =?UTF-8?q?=EB=84=88=EB=9B=B0=EA=B8=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoginViewModel: 미가입 분기·showSignUpNotice 이벤트·안내 문구 제거 — 가입은 UseCase가 자동 수행하므로 성공은 곧 세션 채택 완료 - devSkipTapped 제거 — 데모 폴백 제거 후엔 토큰 없는 홈 진입이 401 → 로그인 시트 복귀 루프의 함정만 남음(시뮬레이터 검수는 Apple 로그인으로) - LoginViewController: 가입 안내 알럿·DEV 건너뛰기 버튼 제거 - Example 스텁·LoginViewModelTests Void 시그니처 반영 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VEZMsiwwexokUxJBEk2C1A --- .../Feature/Auth/Example/ExampleApp.swift | 13 +++---- .../Auth/Sources/LoginViewController.swift | 34 ------------------- .../Feature/Auth/Sources/LoginViewModel.swift | 26 +++----------- .../Auth/Tests/LoginViewModelTests.swift | 32 +++++------------ 4 files changed, 19 insertions(+), 86 deletions(-) diff --git a/Projects/Feature/Auth/Example/ExampleApp.swift b/Projects/Feature/Auth/Example/ExampleApp.swift index 5346ddc3..1f177240 100644 --- a/Projects/Feature/Auth/Example/ExampleApp.swift +++ b/Projects/Feature/Auth/Example/ExampleApp.swift @@ -78,7 +78,7 @@ private final class LauncherViewController: UIViewController { title = "AuthFeature Example" let startButton = UIButton(type: .system) - startButton.setTitle("로그인 시트 열기 (미가입→실패→성공 순환)", for: .normal) + startButton.setTitle("로그인 시트 열기 (실패→성공 순환)", for: .normal) startButton.addAction(UIAction { [weak self] _ in self?.onStart?() }, for: .touchUpInside) startButton.translatesAutoresizingMaskIntoConstraints = false view.addSubview(startButton) @@ -89,7 +89,8 @@ private final class LauncherViewController: UIViewController { } } -/// 탭할 때마다 미가입 안내 → 실패 토스트 → 성공을 순환 — 세 UX를 모두 시연한다. +/// 탭할 때마다 실패 토스트 → 성공을 순환 — 두 UX를 모두 시연한다 +/// (미가입은 UseCase 내부에서 자동 가입으로 흡수돼 화면 분기가 없다). /// (스텁이 Tests와 중복되는 것은 의도된 트레이드오프 — 규약 참고.) @MainActor private final class PreviewSignInUseCase: SignInUseCase { @@ -97,13 +98,9 @@ private final class PreviewSignInUseCase: SignInUseCase { private struct PreviewError: Error {} - func execute(provider: SocialLoginProvider) async throws -> SignInOutcome { + func execute(provider: SocialLoginProvider) async throws { try? await Task.sleep(for: .seconds(1)) defer { attempt += 1 } - switch attempt % 3 { - case 0: return .needsSignUp - case 1: throw PreviewError() - default: return .success - } + if attempt % 2 == 0 { throw PreviewError() } } } diff --git a/Projects/Feature/Auth/Sources/LoginViewController.swift b/Projects/Feature/Auth/Sources/LoginViewController.swift index 544c229e..3ffd61f2 100644 --- a/Projects/Feature/Auth/Sources/LoginViewController.swift +++ b/Projects/Feature/Auth/Sources/LoginViewController.swift @@ -98,10 +98,6 @@ final class LoginViewController: UIViewController { kakaoButton.addTarget(self, action: #selector(didTapKakao), for: .touchUpInside) appleButton.addTarget(self, action: #selector(didTapApple), for: .touchUpInside) - - #if DEV - setupDevSkipButton() - #endif } private func bindViewModel() { @@ -122,14 +118,6 @@ final class LoginViewController: UIViewController { private func handle(_ event: LoginViewModel.Event) { switch event { - case .showSignUpNotice: - let alert = UIAlertController( - title: nil, - message: LoginViewModel.signUpNoticeMessage, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "확인", style: .default)) - present(alert, animated: true) case let .showFailureToast(message): DSToast.show(message, in: view) } @@ -143,28 +131,6 @@ final class LoginViewController: UIViewController { viewModel.appleTapped() } - #if DEV - /// 시뮬레이터 자동 검수용 우회 진입점 — 시트 레이아웃을 건드리지 않도록 dim 상단에 둔다. - private func setupDevSkipButton() { - let skipButton = UIButton(type: .system) - skipButton.setAttributedTitle( - DSTypography.caption1.attributed("DEV 건너뛰기", color: DSColor.Text.tertiary), - for: .normal - ) - skipButton.accessibilityIdentifier = "login.devSkip" - view.addSubview(skipButton) - skipButton.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide).offset(DSSpacing.sm) - make.trailing.equalToSuperview().inset(DSSpacing.lg) - } - skipButton.addTarget(self, action: #selector(didTapDevSkip), for: .touchUpInside) - } - - @objc private func didTapDevSkip() { - viewModel.devSkipTapped() - } - #endif - @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") diff --git a/Projects/Feature/Auth/Sources/LoginViewModel.swift b/Projects/Feature/Auth/Sources/LoginViewModel.swift index a29daa2b..2a3c637c 100644 --- a/Projects/Feature/Auth/Sources/LoginViewModel.swift +++ b/Projects/Feature/Auth/Sources/LoginViewModel.swift @@ -11,12 +11,10 @@ final class LoginViewModel { /// 원샷 안내 — 상태와 분리한다(Home의 ToastEvent 패턴). enum Event: Equatable { - case showSignUpNotice case showFailureToast(message: String) } static let failureMessage = "로그인에 실패했어요. 잠시 후 다시 시도해주세요." - static let signUpNoticeMessage = "아직 가입되지 않은 계정이에요.\n회원가입은 곧 지원될 예정이에요." var onStateChange: ((State) -> Void)? var onEvent: ((Event) -> Void)? @@ -52,17 +50,12 @@ final class LoginViewModel { signInTask = Task { [weak self] in guard let self else { return } do { - let outcome = try await self.signInUseCase.execute(provider: provider) + // 미가입 계정은 UseCase가 최소 가입까지 수행한다 — 성공은 곧 세션 채택 완료. + try await self.signInUseCase.execute(provider: provider) guard !Task.isCancelled else { return } - switch outcome { - case .success: - // 성공 시 idle 복귀 없이 바로 닫는다 — 버튼이 다시 살아나며 - // 깜빡이는 프레임을 만들지 않는다. - self.onAuthenticated?() - case .needsSignUp: - self.state = .idle - self.onEvent?(.showSignUpNotice) - } + // 성공 시 idle 복귀 없이 바로 닫는다 — 버튼이 다시 살아나며 + // 깜빡이는 프레임을 만들지 않는다. + self.onAuthenticated?() } catch SocialLoginError.cancelled { // 사용자가 소셜 UI를 스스로 닫음 — 에러 표출 없이 재시도 대기. guard !Task.isCancelled else { return } @@ -74,13 +67,4 @@ final class LoginViewModel { } } } - - #if DEV - /// 시뮬레이터 자동 검수용 우회(소셜 계정·카카오 키 없이 홈 진입) — - /// 토큰 없이 진행하므로 서버 기능은 DevDemoFallbacks가 가리는 기존 DEV 동작과 같다. - /// 실서버 인증 정식화 시 DevDemoFallbacks와 함께 제거한다. - func devSkipTapped() { - onAuthenticated?() - } - #endif } diff --git a/Projects/Feature/Auth/Tests/LoginViewModelTests.swift b/Projects/Feature/Auth/Tests/LoginViewModelTests.swift index fad6c62a..aba3d00c 100644 --- a/Projects/Feature/Auth/Tests/LoginViewModelTests.swift +++ b/Projects/Feature/Auth/Tests/LoginViewModelTests.swift @@ -7,16 +7,16 @@ import Testing private final class StubSignInUseCase: SignInUseCase { private(set) var executeCallCount = 0 private(set) var providers: [SocialLoginProvider] = [] - private var result: Result + private var result: Result private let suspends: Bool - private var continuation: CheckedContinuation? + private var continuation: CheckedContinuation? - init(result: Result = .success(.success), suspends: Bool = false) { + init(result: Result = .success(()), suspends: Bool = false) { self.result = result self.suspends = suspends } - func execute(provider: SocialLoginProvider) async throws -> SignInOutcome { + func execute(provider: SocialLoginProvider) async throws { executeCallCount += 1 providers.append(provider) if suspends { @@ -25,8 +25,8 @@ private final class StubSignInUseCase: SignInUseCase { return try result.get() } - func resume(with outcome: SignInOutcome) { - continuation?.resume(returning: outcome) + func resumeSuccessfully() { + continuation?.resume(returning: ()) continuation = nil } } @@ -57,7 +57,7 @@ private struct StubError: Error {} struct LoginViewModelTests { @Test func kakaoTapped_success_callsOnAuthenticatedWithoutIdleFlicker() async { - let useCase = StubSignInUseCase(result: .success(.success)) + let useCase = StubSignInUseCase() let sut = LoginViewModel(signInUseCase: useCase) let recorder = Recorder() recorder.attach(to: sut) @@ -73,7 +73,7 @@ struct LoginViewModelTests { @Test func appleTapped_passesAppleProvider() async { - let useCase = StubSignInUseCase(result: .success(.success)) + let useCase = StubSignInUseCase() let sut = LoginViewModel(signInUseCase: useCase) let recorder = Recorder() recorder.attach(to: sut) @@ -84,20 +84,6 @@ struct LoginViewModelTests { #expect(useCase.providers == [.apple]) } - @Test - func tapped_needsSignUp_returnsIdleAndEmitsNotice() async { - let useCase = StubSignInUseCase(result: .success(.needsSignUp)) - let sut = LoginViewModel(signInUseCase: useCase) - let recorder = Recorder() - recorder.attach(to: sut) - - sut.kakaoTapped() - await recorder.waitUntil { recorder.states.last == .idle } - - #expect(recorder.events == [.showSignUpNotice]) - #expect(recorder.authenticatedCount == 0) - } - @Test func tapped_socialCancelled_returnsIdleSilently() async { let useCase = StubSignInUseCase(result: .failure(SocialLoginError.cancelled)) @@ -141,7 +127,7 @@ struct LoginViewModelTests { #expect(useCase.executeCallCount == 1) #expect(recorder.states == [.loading(.kakao)]) - useCase.resume(with: .success) + useCase.resumeSuccessfully() await recorder.waitUntil { recorder.authenticatedCount == 1 } } } From 902c1dea7a6154f4fa8a2cdfb8d04f232eee9f66 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 13 Sep 2026 16:42:22 +0900 Subject: [PATCH 13/13] =?UTF-8?q?feat:=20App=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=E2=80=94=20=EA=B3=84=EC=A0=95=20=EC=8A=A4=ED=83=9D=20=EB=B0=B0?= =?UTF-8?q?=EC=84=A0=C2=B7DevDemoFallbacks=20=EC=A0=9C=EA=B1=B0=C2=B7?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=95=EC=86=8C=20=EC=A1=B4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppDIContainer: SignInUseCase에 위치·역지오코딩 주입(최소 가입 재료), publicPathSuffixes에 /auth/* 5경로 명시(소셜/refresh Bearer가 access 토큰으로 덮이지 않는 이중 방어), UserRepository + Logout/Withdraw UseCase 팩토리 - AuthSessionEndingAdapter: SessionEnding → AuthSessionManager 배선 - DevDemoFallbacks 제거: 실패 은폐 폴백 3종·DEV 3초 타임아웃 삭제 — DEV에서도 실서버 실패가 그대로 표면화된다(전 구성 10s/30s 통일) - DevChangeSimulator 분리 존치: refresh 주입 가로채기 전용 슬림 데코레이터 (register/cancel 에러 은폐 없음) — 막차 변경 검수의 유일 수단 - SceneDelegate DEV 메뉴에 "로그아웃 (DEV)" — 설정 화면 前 유일한 검수 진입점 - CLAUDE.md 미완 상태 절 갱신(가입 완결·로그아웃/탈퇴·폴백 제거 반영) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VEZMsiwwexokUxJBEk2C1A --- CLAUDE.md | 2 +- .../Adapters/AuthSessionEndingAdapter.swift | 17 + .../Sources/Adapters/DevChangeSimulator.swift | 126 ++++++++ .../Sources/Adapters/DevDemoFallbacks.swift | 303 ------------------ Projects/App/Sources/AppDIContainer.swift | 61 ++-- Projects/App/Sources/SceneDelegate.swift | 8 + 6 files changed, 188 insertions(+), 329 deletions(-) create mode 100644 Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift create mode 100644 Projects/App/Sources/Adapters/DevChangeSimulator.swift delete mode 100644 Projects/App/Sources/Adapters/DevDemoFallbacks.swift diff --git a/CLAUDE.md b/CLAUDE.md index 87eb34ec..4d447ec0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho ### 미완 상태 (작업 시 참고) -- **인증은 소셜 로그인(카카오·애플)** — 익명 세션 스텁은 제거됐고, 강제 로그인(게스트 없음)으로 `AuthFeature`가 최초 토큰을 발급받는다(`GET /auth/check`·`/auth/login`, 소셜 토큰을 Bearer로 — plain client 전용). 카카오 키는 `TUIST_KAKAO_APP_KEY` env로 주입(미주입 시 카카오 버튼만 런타임 비활성, DEV는 로그인 시트의 "DEV 건너뛰기"로 우회 가능). 잔여: 카카오/Apple 콘솔에 V2 번들 등록, 서버 audience 허용 확인. Debug(DEV)의 `DevDemoFallbacks`는 실서버 인증 검증 후 제거. +- **인증은 소셜 로그인(카카오·애플), 가입까지 완결** — 강제 로그인(게스트 없음)으로 `AuthFeature`가 최초 토큰을 발급받는다(`GET /auth/check`·`/auth/login`·`POST /auth/sign-up`, 소셜 토큰을 Bearer로 — plain client 전용). 미가입 계정은 `SignInUseCase`가 온보딩 화면 없이 **최소 가입**(현재 위치+역지오코딩 주소, 실패 시 레거시 실측 폴백 `""`·(0,0), 알림 빈도 [1,10])까지 자동 수행한다. 로그아웃(`POST /auth/logout` — refresh 토큰을 Bearer로 싣는 특례, CoreAuth `AuthSessionManager.signOut()`)·회원탈퇴(`DELETE /members/me`)·유저 정보(`GET /members/me`, 홈주소/알림빈도 PATCH — `UserRepository`)는 UseCase까지 구현됐고 **설정 화면은 미구현**(로그아웃 진입점은 DEV 플로팅 메뉴뿐). 카카오 키는 `TUIST_KAKAO_APP_KEY` env로 주입(미주입 시 카카오 버튼만 런타임 비활성). 잔여: 카카오/Apple 콘솔에 V2 번들 등록, 서버 audience 허용 확인. `DevDemoFallbacks`(실패 은폐 데모 폴백)와 "DEV 건너뛰기"는 제거됨 — DEV에 남은 것은 막차 변경 주입용 `DevChangeSimulator`(refresh 가로채기 전용, 에러 은폐 없음)뿐이라 서버 실패가 DEV에서도 그대로 표면화된다. - `AppEnvironment`의 base URL은 dev/live 실주소 반영 완료. **Stage는 dev 호스트를 공유 중** — 전용 호스트만 미정. - `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰 서버 전달도 미구현(로깅만)이라 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)과 홈 pull-to-refresh(수동)뿐. - AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료. diff --git a/Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift b/Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift new file mode 100644 index 00000000..10a2d547 --- /dev/null +++ b/Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift @@ -0,0 +1,17 @@ +import CoreAuth +import Domain + +/// Domain의 세션 종료 포트를 CoreAuth의 AuthSessionManager로 배선한다. +/// 두 경로 모두 sessionExpired yield를 거치므로 로그인 복귀 라우팅은 +/// AppCoordinator의 기존 만료 관찰이 그대로 처리한다. +struct AuthSessionEndingAdapter: SessionEnding { + let sessionManager: AuthSessionManager + + func endSession() async { + await sessionManager.signOut() + } + + func invalidateLocalSession() async { + await sessionManager.invalidateSession() + } +} diff --git a/Projects/App/Sources/Adapters/DevChangeSimulator.swift b/Projects/App/Sources/Adapters/DevChangeSimulator.swift new file mode 100644 index 00000000..003f1404 --- /dev/null +++ b/Projects/App/Sources/Adapters/DevChangeSimulator.swift @@ -0,0 +1,126 @@ +#if DEV +import Domain +import Foundation + +// Phase 11 변경 시뮬레이터 (DEV 한정 — 자동 검수 규약의 전제). +// +// 막차 "변경"(앞당김/늦춤/운행종료) → 판정 → 알람 재스케줄 → LA alert/홈 배너 경로는 +// 실서버가 임의로 재현해줄 수 없다. refresh 주입 가로채기만 남긴 슬림 데코레이터로, +// 과거 DevDemoFallbacks의 "실패 시 데모 데이터·에러 무시" 폴백은 전부 제거됐다 — +// 서버 실패는 이제 DEV에서도 그대로 표면화된다. + +/// AlarmRepository 데코레이터 — 보류 중 주입이 있을 때만 refresh를 가로챈다. +/// register/cancel은 base에 그대로 위임한다(에러 은폐 없음). +struct DevChangeSimulatingAlarmRepository: AlarmRepository { + let base: any AlarmRepository + + func register(lastRouteId: String) async throws { + try await base.register(lastRouteId: lastRouteId) + // 새 세션 시작 — 변경 시뮬레이터가 이 경로를 기준으로 변형한다. + await DevChangeSimulator.shared.noteKnownSession(routeId: lastRouteId, departureTime: nil) + } + + func cancel(lastRouteId: String) async throws { + try await base.cancel(lastRouteId: lastRouteId) + } + + func refresh() async throws -> AlarmInfo { + // 보류 중 변경 주입이 있으면 서버 대신 시뮬레이터가 응답한다(1회 소비). + if let injected = await DevChangeSimulator.shared.consumeInjectedInfo() { + print("⚠️ [DEV 변경 시뮬레이터] 변형 AlarmInfo 반환: departure=\(String(describing: injected.departureTime))") + return injected + } + let info = try await base.refresh() + await DevChangeSimulator.shared.noteKnownSession( + routeId: info.lastRouteId, departureTime: info.departureTime + ) + return info + } +} + +/// 막차 변경 피기백(판정 → 알람 재스케줄 → LA alert/홈 배너)을 검수할 유일한 주입 수단. +/// `DevChangeSimulatingAlarmRepository.refresh()`가 보류 중 주입을 소비해, +/// 마지막으로 알려진 출발 시각 기준으로 변형된 AlarmInfo를 반환한다. +@MainActor +final class DevChangeSimulator { + static let shared = DevChangeSimulator() + + enum Injection { + /// 출발 시각을 N초 앞당긴다 → advanced 판정 유도. + case advance(TimeInterval) + /// 출발 시각을 N초 늦춘다 → delayed 판정 유도. + case delay(TimeInterval) + /// 운행 종료 — departureTime 없는 응답으로 sessionEnded 판정 유도. + case end + } + + /// 알려진 세션의 UserDefaults 키 (Phase 14 검수) — 강제 종료·재실행 검수에서 + /// 기준 출발 시각을 잃으면 주입 diff·카드 복원 시각이 어긋나므로 DEV 한정 영속화한다. + private static let knownRouteIdKey = "dev.sim.knownRouteId" + private static let knownDepartureKey = "dev.sim.knownDeparture" + + private var pending: Injection? + /// 마지막으로 알려진 세션 — 등록·refresh 성공·주입 적용 시 갱신된다. + private var knownRouteId: String? + private var knownDepartureTime: Date? + + private init() { + knownRouteId = UserDefaults.standard.string(forKey: Self.knownRouteIdKey) + let epoch = UserDefaults.standard.double(forKey: Self.knownDepartureKey) + knownDepartureTime = epoch > 0 ? Date(timeIntervalSince1970: epoch) : nil + } + + /// 주입 예약 — 다음 refresh() 1회가 소비한다. + func inject(_ injection: Injection) { + pending = injection + print("⚠️ [DEV 변경 시뮬레이터] 주입 보류: \(injection)") + } + + /// departureTime이 nil이면 routeId만 갱신한다(등록 경로는 출발 시각을 모른다). + func noteKnownSession(routeId: String, departureTime: Date?) { + knownRouteId = routeId + UserDefaults.standard.set(routeId, forKey: Self.knownRouteIdKey) + if let departureTime { + knownDepartureTime = departureTime + UserDefaults.standard.set( + departureTime.timeIntervalSince1970, forKey: Self.knownDepartureKey + ) + } + } + + /// 알려진 세션의 출발 시각 — routeId가 일치할 때만 (상세 복원의 시각 정합용). + func knownSessionDeparture(routeId: String) -> Date? { + knownRouteId == routeId ? knownDepartureTime : nil + } + + /// 보류 중 주입을 소비해 변형된 AlarmInfo를 만든다. 주입이 없으면 nil. + /// 적용 결과를 기준 시각으로 다시 캐시하므로 주입을 연달아 합성할 수 있다 + /// (예: "10분 늦춤" 뒤 "5분 앞당김" → actionable alert 경로 검수). + func consumeInjectedInfo(now: Date = Date()) -> AlarmInfo? { + guard let injection = pending else { return nil } + pending = nil + let routeId = knownRouteId ?? "dev-demo-route" + // 기준 출발 시각: 캐시가 없으면 데모 경로 기본값(now+3분)과 같은 가정. + let base = knownDepartureTime ?? now.addingTimeInterval(3 * 60) + + let departure: Date? + switch injection { + case .advance(let seconds): departure = base.addingTimeInterval(-seconds) + case .delay(let seconds): departure = base.addingTimeInterval(seconds) + case .end: departure = nil + } + knownDepartureTime = departure + if let departure { + UserDefaults.standard.set( + departure.timeIntervalSince1970, forKey: Self.knownDepartureKey + ) + } + return AlarmInfo( + lastRouteId: routeId, + departureTime: departure, + updatedAt: now, + isReal: true + ) + } +} +#endif diff --git a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift deleted file mode 100644 index cd5def65..00000000 --- a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift +++ /dev/null @@ -1,303 +0,0 @@ -#if DEV -import Domain -import Foundation - -// DEV 검수용 임시 우회 (Phase 7 사람 검수 — 사용자 지시로 추가). -// -// 실서버가 미확정 입력 #1(실 base URL)·#2(익명 인증) 상태라 접속 불가인 동안에도 -// 전체 플로우(검색 → 경로 선택 → 알람 등록 → 발화 → 해제)를 시연할 수 있게, -// 서버 호출이 "실패했을 때만" 데모 데이터로 대체하거나 실패를 무시한다. -// 서버가 살아나면 실데이터가 그대로 우선한다. Stage/Release에는 컴파일되지 않는다. -// 실서버 스펙 확정 시 이 파일과 AppDIContainer의 #if DEV 주입만 제거하면 된다. - -struct DevDemoFallbackPlaceRepository: PlaceRepository { - let base: any PlaceRepository - - /// Phase 17 검수 훅 — 이 좌표의 장소를 도착지로 고르면 경로 검색이 빈 목록을 반환한다 - /// (빈 목록 정규화 = serviceEnded 표면의 유일한 DEV 재현 수단). - static let emptyRouteCoordinate = Coordinate(latitude: 0, longitude: 0) - - func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { - // Phase 17 검수 훅 — 예약 키워드는 base보다 먼저 판정한다("실패 시에만" 원칙의 - // 명시적 예외, 검수 결정론 확보). 빈 상태·serviceEnded 화면의 재현 재료. - if keyword == "결과없음" { - print("⚠️ [DEV 검수 훅] 예약 키워드 → 장소 0건 반환") - return [] - } - if keyword == "경로없음" { - print("⚠️ [DEV 검수 훅] 예약 키워드 → 빈 경로 유도 데모 장소 반환") - return [ - Place( - name: "경로없음 (데모)", - address: "도착지로 선택하면 빈 경로 응답을 시연해요", - coordinate: Self.emptyRouteCoordinate - ), - ] - } - do { return try await base.searchPlaces(keyword: keyword, near: coordinate) } - catch { - print("⚠️ [DEV 우회] 장소 검색 실패 → 데모 장소 반환: \(error)") - return [ - Place( - name: "\(keyword) (데모)", - address: "서울 강남구 강남대로 396", - coordinate: Coordinate(latitude: 37.4979, longitude: 127.0276) - ), - Place( - name: "구로디지털단지역 (데모)", - address: "서울 구로구 도림천로 486", - coordinate: Coordinate(latitude: 37.4853, longitude: 126.9015) - ), - ] - } - } - - func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { - do { return try await base.reverseGeocode(coordinate) } - catch { - print("⚠️ [DEV 우회] 역지오코딩 실패 → 데모 라벨 반환: \(error)") - return Place(name: "현재 위치 (데모)", address: "", coordinate: coordinate) - } - } -} - -struct DevDemoFallbackLastRouteRepository: LastRouteRepository { - let base: any LastRouteRepository - - func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] { - // Phase 17 검수 훅 — 예약 도착지(경로없음 데모 장소)는 base보다 먼저 판정한다. - // 빈 목록은 정규화 가정(미확정 #3)에 따라 serviceEnded로 표면화된다. - if end == DevDemoFallbackPlaceRepository.emptyRouteCoordinate { - print("⚠️ [DEV 검수 훅] 예약 도착지 → 빈 경로 목록 반환 (serviceEnded 정규화 재료)") - return [] - } - do { return try await base.searchLastRoutes(start: start, end: end) } - catch { - print("⚠️ [DEV 우회] 막차 검색 실패 → 데모 경로 반환: \(error)") - let route = Self.demoRoute(start: start, end: end) - // 변경 시뮬레이터의 기준 출발 시각 — 데모 흐름에선 검색 직후 이 경로가 등록된다. - // 대안(내일 출발)은 기준 시각에 관여하지 않는다 — index 0 정합 불변. - await DevChangeSimulator.shared.noteKnownSession( - routeId: route.id, departureTime: route.departureTime - ) - return [route, Self.tomorrowDemoRoute(start: start, end: end)] - } - } - - func lastRoute(id: String) async throws -> LastRoute { - do { return try await base.lastRoute(id: id) } - catch { - print("⚠️ [DEV 우회] 경로 상세 실패 → 데모 경로 반환: \(error)") - // 카드 복원(Phase 14) 검수 정합: 알려진 세션(재실행 후에도 영속)의 출발 시각을 - // 재사용해야 배너·LA와 카드의 시각이 어긋나지 않는다. 모르면 새 데모 시각. - let knownDeparture = await DevChangeSimulator.shared.knownSessionDeparture(routeId: id) - let route = Self.demoRoute( - start: Coordinate(latitude: 37.4979, longitude: 127.0276), - end: Coordinate(latitude: 37.4853, longitude: 126.9015), - departure: knownDeparture - ) - await DevChangeSimulator.shared.noteKnownSession( - routeId: route.id, departureTime: route.departureTime - ) - return route - } - } - - /// 오늘/내일 라벨(Phase 17) 검수 재료 — 더보기의 대안 경로로 항상 "내일" 출발이 - /// 표시되게 다음 자정+10분을 출발 시각으로 잡는다(검수 시각 무관 결정론). - /// 알람 등록 검수 플로우는 featured(index 0)를 쓰므로 이 경로와 무관하다. - private static func tomorrowDemoRoute(start: Coordinate, end: Coordinate) -> LastRoute { - let startOfToday = Calendar.current.startOfDay(for: Date()) - let nextMidnight = Calendar.current.date(byAdding: .day, value: 1, to: startOfToday) - ?? startOfToday.addingTimeInterval(24 * 60 * 60) - return demoRoute( - start: start, - end: end, - departure: nextMidnight.addingTimeInterval(10 * 60), - id: "dev-demo-route-tomorrow" - ) - } - - /// 출발 시각은 8분 뒤 — 알람이 도보(첫 walk leg 120초)+버퍼(180초) 반영으로 +3분 - /// 시점에 걸린다(Phase 14 검수 ③: 배너·LA·발화가 전부 "출발 − 도보 − 3분" 기준). - /// 더 이르면 등록 탭 시점에 이미 과거가 되어 tooLate 가드·AlarmKit 거부에 걸린다. - private static func demoRoute( - start: Coordinate, end: Coordinate, departure: Date? = nil, id: String = "dev-demo-route" - ) -> LastRoute { - let departure = departure ?? Date().addingTimeInterval(8 * 60) - return LastRoute( - id: id, - departureTime: departure, - totalTime: 2940, - totalWalkTime: 480, - transferCount: 1, - totalDistance: 14200, - totalWalkDistance: 700, - legs: [ - TransportLeg( - mode: .walk, - sectionTime: 120, - distance: 150, - departureTime: nil, - routeName: nil, - lineType: nil, - start: nil, - end: RoutePoint(name: "강남역", coordinate: start), - subwayFinalStation: nil, - subwayDirection: nil, - isExpressSubway: false, - isLastSubway: false - ), - TransportLeg( - mode: .subway, - sectionTime: 1500, - distance: 9000, - departureTime: departure, - routeName: "2호선", - lineType: "2", - start: RoutePoint(name: "강남역", coordinate: start), - end: RoutePoint(name: "당산역", coordinate: Coordinate(latitude: 37.5343, longitude: 126.9024)), - subwayFinalStation: "홍대입구행", - subwayDirection: "외선", - isExpressSubway: false, - isLastSubway: true - ), - TransportLeg( - mode: .bus, - sectionTime: 960, - distance: 4500, - departureTime: nil, - routeName: "간선:6411", - lineType: "11", - start: RoutePoint(name: "당산역", coordinate: Coordinate(latitude: 37.5343, longitude: 126.9024)), - end: RoutePoint(name: "구로디지털단지", coordinate: end), - subwayFinalStation: nil, - subwayDirection: nil, - isExpressSubway: false, - isLastSubway: false - ), - ] - ) - } -} - -/// 서버 알람 등록/삭제 실패를 무시해 로컬 스케줄(AlarmKit)까지 진행시킨다. -/// refresh는 그대로 실패시킨다 — 홈이 상태를 유지하므로 시연에 지장이 없다. -/// 단, Phase 11 변경 시뮬레이터의 주입이 보류 중이면 서버 대신 변형 AlarmInfo를 반환한다. -struct DevDemoTolerantAlarmRepository: AlarmRepository { - let base: any AlarmRepository - - func register(lastRouteId: String) async throws { - do { try await base.register(lastRouteId: lastRouteId) } - catch { print("⚠️ [DEV 우회] 서버 알람 등록 실패 무시 → 로컬 스케줄 진행: \(error)") } - // 새 세션 시작 — 변경 시뮬레이터가 이 경로를 기준으로 변형한다. - await DevChangeSimulator.shared.noteKnownSession(routeId: lastRouteId, departureTime: nil) - } - - func cancel(lastRouteId: String) async throws { - do { try await base.cancel(lastRouteId: lastRouteId) } - catch { print("⚠️ [DEV 우회] 서버 알람 삭제 실패 무시 → 로컬 취소 진행: \(error)") } - } - - func refresh() async throws -> AlarmInfo { - // Phase 11 검수: 보류 중 변경 주입이 있으면 서버 대신 시뮬레이터가 응답한다(1회 소비). - if let injected = await DevChangeSimulator.shared.consumeInjectedInfo() { - print("⚠️ [DEV 변경 시뮬레이터] 변형 AlarmInfo 반환: departure=\(String(describing: injected.departureTime))") - return injected - } - let info = try await base.refresh() - await DevChangeSimulator.shared.noteKnownSession( - routeId: info.lastRouteId, departureTime: info.departureTime - ) - return info - } -} - -// MARK: - Phase 11 변경 시뮬레이터 (DEV 한정 — 사람 검수의 전제) - -/// 막차 변경 피기백(판정 → 알람 재스케줄 → LA alert/홈 배너)을 검수할 유일한 주입 수단. -/// 실서버가 변경을 내려줄 수 없는 동안 `DevDemoTolerantAlarmRepository.refresh()`가 -/// 보류 중 주입을 소비해, 마지막으로 알려진 출발 시각 기준으로 변형된 AlarmInfo를 반환한다. -@MainActor -final class DevChangeSimulator { - static let shared = DevChangeSimulator() - - enum Injection { - /// 출발 시각을 N초 앞당긴다 → advanced 판정 유도. - case advance(TimeInterval) - /// 출발 시각을 N초 늦춘다 → delayed 판정 유도. - case delay(TimeInterval) - /// 운행 종료 — departureTime 없는 응답으로 sessionEnded 판정 유도. - case end - } - - /// 알려진 세션의 UserDefaults 키 (Phase 14 검수) — 강제 종료·재실행 검수에서 - /// 기준 출발 시각을 잃으면 주입 diff·카드 복원 시각이 어긋나므로 DEV 한정 영속화한다. - private static let knownRouteIdKey = "dev.sim.knownRouteId" - private static let knownDepartureKey = "dev.sim.knownDeparture" - - private var pending: Injection? - /// 마지막으로 알려진 세션 — 데모 경로 생성·등록·refresh 성공·주입 적용 시 갱신된다. - private var knownRouteId: String? - private var knownDepartureTime: Date? - - private init() { - knownRouteId = UserDefaults.standard.string(forKey: Self.knownRouteIdKey) - let epoch = UserDefaults.standard.double(forKey: Self.knownDepartureKey) - knownDepartureTime = epoch > 0 ? Date(timeIntervalSince1970: epoch) : nil - } - - /// 주입 예약 — 다음 refresh() 1회가 소비한다. - func inject(_ injection: Injection) { - pending = injection - print("⚠️ [DEV 변경 시뮬레이터] 주입 보류: \(injection)") - } - - /// departureTime이 nil이면 routeId만 갱신한다(등록 경로는 출발 시각을 모른다). - func noteKnownSession(routeId: String, departureTime: Date?) { - knownRouteId = routeId - UserDefaults.standard.set(routeId, forKey: Self.knownRouteIdKey) - if let departureTime { - knownDepartureTime = departureTime - UserDefaults.standard.set( - departureTime.timeIntervalSince1970, forKey: Self.knownDepartureKey - ) - } - } - - /// 알려진 세션의 출발 시각 — routeId가 일치할 때만 (데모 상세의 시각 정합용). - func knownSessionDeparture(routeId: String) -> Date? { - knownRouteId == routeId ? knownDepartureTime : nil - } - - /// 보류 중 주입을 소비해 변형된 AlarmInfo를 만든다. 주입이 없으면 nil. - /// 적용 결과를 기준 시각으로 다시 캐시하므로 주입을 연달아 합성할 수 있다 - /// (예: "10분 늦춤" 뒤 "5분 앞당김" → actionable alert 경로 검수). - func consumeInjectedInfo(now: Date = Date()) -> AlarmInfo? { - guard let injection = pending else { return nil } - pending = nil - let routeId = knownRouteId ?? "dev-demo-route" - // 기준 출발 시각: 캐시가 없으면 데모 경로 기본값(now+3분)과 같은 가정. - let base = knownDepartureTime ?? now.addingTimeInterval(3 * 60) - - let departure: Date? - switch injection { - case .advance(let seconds): departure = base.addingTimeInterval(-seconds) - case .delay(let seconds): departure = base.addingTimeInterval(seconds) - case .end: departure = nil - } - knownDepartureTime = departure - if let departure { - UserDefaults.standard.set( - departure.timeIntervalSince1970, forKey: Self.knownDepartureKey - ) - } - return AlarmInfo( - lastRouteId: routeId, - departureTime: departure, - updatedAt: now, - isReal: true - ) - } -} -#endif diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index b559df21..95707684 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -50,17 +50,6 @@ final class AppDIContainer { #endif init() { - #if DEV - // 검수용 임시 우회: 실서버(미확정 #1·#2)가 죽어 있어도 기본 60초 타임아웃 대기로 - // 시연이 멈추지 않게 짧은 타임아웃을 쓴다. DevDemoFallbacks와 함께 제거한다. - let sessionConfiguration = URLSessionConfiguration.ephemeral - sessionConfiguration.timeoutIntervalForRequest = 3 - sessionConfiguration.timeoutIntervalForResource = 5 - let baseClient = URLSessionNetworkClient( - baseURL: AppEnvironment.current.apiBaseURL, - session: URLSession(configuration: sessionConfiguration) - ) - #else // Phase 16 — 기본 60초 타임아웃은 심야의 약한 연결에서 1분 침묵이다. 빨리 // 실패시키고(요청 10초) 멱등 GET 1회 재시도·신선도 스탬프가 정직성을 맡는다. let sessionConfiguration = URLSessionConfiguration.default @@ -70,7 +59,6 @@ final class AppDIContainer { baseURL: AppEnvironment.current.apiBaseURL, session: URLSession(configuration: sessionConfiguration) ) - #endif self.plainNetworkClient = baseClient let sessionManager = AuthSessionManager( tokenStore: TokenStore(store: KeychainStore()), @@ -81,25 +69,25 @@ final class AppDIContainer { self.authSessionManager = sessionManager let networkClient = AuthenticatedNetworkClient( base: baseClient, - sessionManager: sessionManager + sessionManager: sessionManager, + // /auth/* 는 전부 plain client 또는 CoreAuth 내부에서 특수 Bearer(소셜·refresh)로 + // 호출되지만, 실수로 이 데코레이터를 타도 그 Authorization이 서버 access 토큰으로 + // 조용히 덮이지 않도록 이중 방어로 전부 public 처리한다(레거시 allowlist 대응). + publicPathSuffixes: [ + "/auth/reissue", "/auth/check", "/auth/login", "/auth/sign-up", "/auth/logout", + ] ) self.networkClient = networkClient + self.placeRepository = PlaceRepositoryImpl(networkClient: networkClient) + self.lastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient) #if DEV - // 검수용 임시 우회 — 실서버(미확정 #1·#2) 부재 시에만 데모 데이터로 폴백. - // 실서버 확정 시 이 블록과 DevDemoFallbacks.swift를 제거한다. - self.placeRepository = DevDemoFallbackPlaceRepository( - base: PlaceRepositoryImpl(networkClient: networkClient) - ) - self.lastRouteRepository = DevDemoFallbackLastRouteRepository( - base: LastRouteRepositoryImpl(networkClient: networkClient) - ) - self.alarmRepository = DevDemoTolerantAlarmRepository( + // 막차 "변경"(앞당김/늦춤/운행종료)은 실서버가 임의로 재현해줄 수 없다 — + // refresh 주입 가로채기만 남긴 슬림 데코레이터(에러 은폐 없음, Phase 11 검수 수단). + self.alarmRepository = DevChangeSimulatingAlarmRepository( base: AlarmRepositoryImpl(networkClient: networkClient) ) #else - self.placeRepository = PlaceRepositoryImpl(networkClient: networkClient) - self.lastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient) self.alarmRepository = AlarmRepositoryImpl(networkClient: networkClient) #endif @@ -156,11 +144,34 @@ final class AppDIContainer { // plain client — 소셜 Bearer 보존 + 401이 세션 복구를 촉발하지 않게. authRepository: AuthRepositoryImpl(networkClient: plainNetworkClient), sessionStore: AuthSessionStoreAdapter(sessionManager: authSessionManager), - pushTokenProvider: FCMPushTokenAdapter() + pushTokenProvider: FCMPushTokenAdapter(), + // 최소 가입 폼 재료 — 신규 계정일 때만 쓰인다(현재 위치 + 역지오코딩 주소). + getCurrentLocationUseCase: DefaultGetCurrentLocationUseCase( + locationService: CoreLocationServiceAdapter() + ), + reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository) ) ) } + // MARK: - 계정 스택 (설정 화면 前 단계 — 로그아웃은 DEV 디버그 메뉴가 유일한 진입점) + + private var userRepository: any UserRepository { + UserRepositoryImpl(networkClient: networkClient) + } + + private var sessionEnding: any SessionEnding { + AuthSessionEndingAdapter(sessionManager: authSessionManager) + } + + func makeLogoutUseCase() -> any LogoutUseCase { + DefaultLogoutUseCase(sessionEnding: sessionEnding) + } + + func makeWithdrawUseCase() -> any WithdrawUseCase { + DefaultWithdrawUseCase(userRepository: userRepository, sessionEnding: sessionEnding) + } + func makeHomeDIContainer() -> any HomeCoordinatorBuildable { let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore()) let locationService = CoreLocationServiceAdapter() diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index 7cbb9acf..b9b8a6b1 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -102,6 +102,14 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { add("10분 늦춤 (3초 후)", injection: .delay(10 * 60), delay: 3) add("운행 종료 (3초 후)", injection: .end, delay: 3, style: .destructive) add("즉시 refresh", injection: nil, delay: 0) + // 로그아웃 엔드투엔드 검수(설정 화면 前 유일한 진입점) — 서버 /auth/logout 베스트 + // 에포트 후 sessionExpired 경유로 로그인 시트에 복귀해야 정상이다. + sheet.addAction(UIAlertAction(title: "로그아웃 (DEV)", style: .destructive) { _ in + Task { @MainActor in + await container.makeLogoutUseCase().execute() + print("⚠️ [DEV] 로그아웃 실행 — sessionExpired 라우팅으로 로그인 시트 복귀 기대") + } + }) // Phase 12 dismiss 폴백 검수용 — 잠금화면 스와이프 자동화가 불안정할 때 기록을 강제한다. // 켠 뒤 앞당김/운행 종료를 주입하면 LA alert 대신 로컬 노티 경로를 탄다. // 표시용 현재값은 UserDefaults 동기 읽기 — 어댑터가 캐시와 함께 갱신하므로 어긋나지 않는다.