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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions Projects/App/Sources/AppDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,17 @@ final class AppDIContainer {
let locationService = CoreLocationServiceAdapter()
let getCurrentLocation: any GetCurrentLocationUseCase =
DefaultGetCurrentLocationUseCase(locationService: locationService)
// 원탭 칩(Phase 18) — 검색과 홈이 같은 인스턴스를 봐야 검색의 저장·삭제가
// 칩에 그대로 비친다 (recentSearchRepository 1회 생성과 같은 이유).
let searchLastRoutes: any SearchLastRoutesUseCase =
DefaultSearchLastRoutesUseCase(repository: lastRouteRepository)
let recentSearches: any RecentSearchesUseCase =
DefaultRecentSearchesUseCase(repository: recentSearchRepository)

let searchContainer = SearchDIContainer(
searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository),
searchLastRoutesUseCase: DefaultSearchLastRoutesUseCase(repository: lastRouteRepository),
recentSearchesUseCase: DefaultRecentSearchesUseCase(repository: recentSearchRepository),
searchLastRoutesUseCase: searchLastRoutes,
recentSearchesUseCase: recentSearches,
getCurrentLocationUseCase: getCurrentLocation
)

Expand Down Expand Up @@ -185,6 +191,9 @@ final class AppDIContainer {
getLastRouteDetailUseCase: DefaultGetLastRouteDetailUseCase(
repository: lastRouteRepository
),
// 원탭 칩(Phase 18) — 검색 화면과 같은 인스턴스 공유(위 주석 참조).
searchLastRoutesUseCase: searchLastRoutes,
recentSearchesUseCase: recentSearches,
searchCoordinatorBuildable: searchContainer
)
}
Expand Down
15 changes: 15 additions & 0 deletions Projects/DesignSystem/Example/ComponentDemos.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ final class ButtonsDemoViewController: GalleryScreenViewController {
let disabled = DSButton(title: "비활성", style: .primary)
disabled.isEnabled = false
contentStack.addArrangedSubview(disabled)

addSectionTitle("DSChip")
let chip = DSChip()
chip.setText("→ 신림동")
let disabledChip = DSChip()
disabledChip.setText("→ 구로디지털단지역")
disabledChip.isEnabled = false
[chip, disabledChip].forEach { contentStack.addArrangedSubview(leadingRow($0)) }
}

/// 칩은 자기 크기 컴포넌트 — 스택 전폭으로 늘리지 않고 leading에 붙인다(홈과 같은 배치).
private func leadingRow(_ view: UIView) -> UIStackView {
let row = UIStackView(arrangedSubviews: [view, UIView()])
row.axis = .horizontal
return row
}
}

Expand Down
52 changes: 52 additions & 0 deletions Projects/DesignSystem/Sources/Components/DSChip.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import UIKit

/// 컴팩트 pill 칩 — 최근 경로 원탭(Phase 18)처럼 내용이 데이터를 따라 바뀌는 표면용.
/// DSButton은 title이 init 고정이라(홈의 등록/해제 버튼 2개 우회가 그 기록) 동적
/// 텍스트 자리에 부적합하다 — setText 갱신 가능이 이 컴포넌트의 존재 이유다.
public final class DSChip: UIButton {
private static let height: CGFloat = 32

public init() {
super.init(frame: .zero)

var configuration = UIButton.Configuration.filled()
// 높이 절반 = pill. DSRadius.lg(16)가 그 값과 일치한다.
configuration.background.cornerRadius = DSRadius.lg
configuration.cornerStyle = .fixed
configuration.contentInsets = .init(
top: 0, leading: DSSpacing.md, bottom: 0, trailing: DSSpacing.md
)
// Colors are applied eagerly so the initial configuration is complete
// without waiting for an update pass (which never runs in hostless
// tests); the update handler keeps them in sync with state changes.
Self.applyColors(&configuration, isEnabled: true)
self.configuration = configuration

configurationUpdateHandler = { button in
guard var configuration = button.configuration else { return }
Self.applyColors(&configuration, isEnabled: button.isEnabled)
button.configuration = configuration
}
}

public func setText(_ text: String) {
configuration?.attributedTitle = AttributedString(
text, attributes: AttributeContainer([.font: DSTypography.label2.font])
)
}

static func applyColors(_ configuration: inout UIButton.Configuration, isEnabled: Bool) {
// secondary 계열 매핑(DSButton.secondary와 동일 척도) — 강조가 아니라 보조 진입점이다.
configuration.baseBackgroundColor = isEnabled ? DSColor.Fill.elevated : DSColor.Fill.surface
configuration.baseForegroundColor = isEnabled ? DSColor.Text.primary : DSColor.Text.disabled
}

public override var intrinsicContentSize: CGSize {
CGSize(width: super.intrinsicContentSize.width, height: Self.height)
}

@available(*, unavailable)
public required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
}
46 changes: 46 additions & 0 deletions Projects/DesignSystem/Tests/DSChipTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
@testable import DesignSystem
import Testing
import UIKit

@MainActor
struct DSChipTests {
@Test
func setTextUpdatesTitle() {
let chip = DSChip()
chip.setText("→ 신림동")
#expect(chip.configuration?.title == "→ 신림동")
chip.setText("→ 강남역")
#expect(chip.configuration?.title == "→ 강남역")
}

@Test
func enabledUsesSecondaryColors() {
let chip = DSChip()
let configuration = chip.configuration
#expect(configuration?.baseBackgroundColor.map {
colorsMatch($0, DSColor.Fill.elevated)
} == true)
#expect(configuration?.baseForegroundColor.map {
colorsMatch($0, DSColor.Text.primary)
} == true)
}

@Test
func disabledAppearanceMutesColors() {
var configuration = UIButton.Configuration.filled()
DSChip.applyColors(&configuration, isEnabled: false)
#expect(configuration.baseBackgroundColor.map {
colorsMatch($0, DSColor.Fill.surface)
} == true)
#expect(configuration.baseForegroundColor.map {
colorsMatch($0, DSColor.Text.disabled)
} == true)
}

@Test
func pillShapeIsFixedHeight() {
let chip = DSChip()
#expect(chip.intrinsicContentSize.height == 32)
#expect(chip.configuration?.background.cornerRadius == DSRadius.lg)
}
}
20 changes: 20 additions & 0 deletions Projects/Feature/Home/Example/ExampleApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
observeAlarmChangeUseCase: PreviewObserveAlarmChangeUseCase(),
requestAlarmSyncUseCase: PreviewRequestAlarmSyncUseCase(),
getLastRouteDetailUseCase: PreviewGetLastRouteDetailUseCase(),
searchLastRoutesUseCase: PreviewSearchLastRoutesUseCase(),
recentSearchesUseCase: PreviewRecentSearchesUseCase(),
searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable()
)
let coordinator = container.makeHomeCoordinator(navigationController: navigationController)
Expand Down Expand Up @@ -116,6 +118,24 @@ struct PreviewGetLastRouteDetailUseCase: GetLastRouteDetailUseCase {
}
}

/// 원탭 칩 재검색 스텁(Phase 18) — canned 경로 1건을 돌려줘 칩 탭 → 카드 시연이 성립한다.
struct PreviewSearchLastRoutesUseCase: SearchLastRoutesUseCase {
func execute(start: Coordinate, end: Coordinate) async throws -> LastRouteSearchResult {
try? await Task.sleep(for: .milliseconds(400))
return .available([PreviewSearchCoordinator.makeCannedRoute()])
}
}

/// 최근 검색 스텁(Phase 18) — canned 1건으로 칩이 즉시 표출된다. save/remove는 no-op.
struct PreviewRecentSearchesUseCase: RecentSearchesUseCase {
func fetch() async throws -> [Place] {
[PreviewSearchCoordinator.makeCannedArrival()]
}

func save(_ place: Place) async throws {}
func remove(_ place: Place) async throws {}
}

/// 검색 플로우 스텁: 화면 전환 없이 canned 경로를 즉시 반환한다.
/// 실제 검색 UX 시연은 SearchFeatureExample이 담당한다.
struct PreviewSearchCoordinatorBuildable: SearchCoordinatorBuildable {
Expand Down
10 changes: 9 additions & 1 deletion Projects/Feature/Home/Sources/HomeDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable {
private let observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase
private let requestAlarmSyncUseCase: any RequestAlarmSyncUseCase
private let getLastRouteDetailUseCase: any GetLastRouteDetailUseCase
private let searchLastRoutesUseCase: any SearchLastRoutesUseCase
private let recentSearchesUseCase: any RecentSearchesUseCase
private let searchCoordinatorBuildable: any SearchCoordinatorBuildable

public init(
Expand All @@ -27,6 +29,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable {
observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase,
requestAlarmSyncUseCase: any RequestAlarmSyncUseCase,
getLastRouteDetailUseCase: any GetLastRouteDetailUseCase,
searchLastRoutesUseCase: any SearchLastRoutesUseCase,
recentSearchesUseCase: any RecentSearchesUseCase,
searchCoordinatorBuildable: any SearchCoordinatorBuildable
) {
self.getCurrentLocationUseCase = getCurrentLocationUseCase
Expand All @@ -37,6 +41,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable {
self.observeAlarmChangeUseCase = observeAlarmChangeUseCase
self.requestAlarmSyncUseCase = requestAlarmSyncUseCase
self.getLastRouteDetailUseCase = getLastRouteDetailUseCase
self.searchLastRoutesUseCase = searchLastRoutesUseCase
self.recentSearchesUseCase = recentSearchesUseCase
self.searchCoordinatorBuildable = searchCoordinatorBuildable
}

Expand All @@ -58,7 +64,9 @@ public final class HomeDIContainer: HomeCoordinatorBuildable {
observeAlarmUseCase: observeAlarmUseCase,
observeAlarmChangeUseCase: observeAlarmChangeUseCase,
requestAlarmSyncUseCase: requestAlarmSyncUseCase,
getLastRouteDetailUseCase: getLastRouteDetailUseCase
getLastRouteDetailUseCase: getLastRouteDetailUseCase,
searchLastRoutesUseCase: searchLastRoutesUseCase,
recentSearchesUseCase: recentSearchesUseCase
)
viewModel.onSearchRequested = onSearchRequested
return HomeViewController(viewModel: viewModel)
Expand Down
41 changes: 40 additions & 1 deletion Projects/Feature/Home/Sources/HomeViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ final class HomeViewController: UIViewController {
private lazy var arrivalRow = makeFieldRow(
icon: DSIcon.place24, field: arrivalField, entry: .arrival
)
// 최근 경로 원탭 칩(Phase 18) — 자기 크기 컴포넌트라 스택 전폭으로 늘리지 않고
// 래퍼의 leading에 붙인다. 표시·활성은 render가 State로 반영한다.
private let recentRouteChip = DSChip()
private lazy var chipRow: UIView = {
let row = UIView()
row.addSubview(recentRouteChip)
recentRouteChip.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview()
}
return row
}()
private let routeCard = DSRouteCard()
private let registerButton = DSButton(title: "알람 등록하기")
// DSButton은 title이 init 고정이라 토글은 버튼 2개의 표시 전환으로 구현한다.
Expand Down Expand Up @@ -79,6 +91,8 @@ final class HomeViewController: UIViewController {
super.viewWillAppear(animated)
// 홈은 자체 타이틀을 그린다 — 시스템 내비바 숨김(Search와 동일 규약).
navigationController?.setNavigationBarHidden(true, animated: animated)
// 검색 화면을 다녀오며 바뀐 최근 검색(삭제 포함)을 칩에 반영한다(Phase 18).
viewModel.viewWillAppear()
}

// MARK: - UI
Expand Down Expand Up @@ -106,18 +120,25 @@ final class HomeViewController: UIViewController {
make.width.equalTo(scrollView.frameLayoutGuide).offset(-DSSpacing.md * 2)
}

[titleLabel, banner, departureRow, arrivalRow, routeCard, registerButton, cancelButton]
[titleLabel, banner, departureRow, arrivalRow, chipRow, routeCard, registerButton, cancelButton]
.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)

banner.isHidden = true
chipRow.isHidden = true
routeCard.isHidden = true
registerButton.isHidden = true
cancelButton.isHidden = true

recentRouteChip.addAction(
UIAction { [weak self] _ in self?.viewModel.chipTapped() },
for: .touchUpInside
)
registerButton.addAction(
UIAction { [weak self] _ in self?.viewModel.registerAlarmTapped() },
for: .touchUpInside
Expand Down Expand Up @@ -221,6 +242,15 @@ final class HomeViewController: UIViewController {
// 도착지 필드 = 선택 경로의 도착지명(Phase 17). nil이면 placeholder가 유도한다.
arrivalField.setText(state.arrivalText ?? "")

// 최근 경로 원탭 칩(Phase 18) — nil이면 숨김, 재검색 진행 중엔 비활성(더블 탭 방지).
if let chipText = state.recentRouteChipText {
recentRouteChip.setText(chipText)
chipRow.isHidden = false
} else {
chipRow.isHidden = true
}
recentRouteChip.isEnabled = !state.isChipBusy

if let card = state.routeCard {
// 신선도 스탬프(Phase 16)는 세션 상태라 State가 따로 나른다 — 표출 시점 합성.
routeCard.configure(with: card.dsContent(footnote: state.freshnessText))
Expand Down Expand Up @@ -277,6 +307,15 @@ final class HomeViewController: UIViewController {
case .locationRestricted:
// restricted는 설정으로 못 푸는 제약 — "설정으로 이동"을 안내하지 않는다(Phase 17).
DSToast.show("이 기기에선 위치를 사용할 수 없어요. 출발지를 검색해 주세요", in: view)
case .chipLocationUnavailable:
DSToast.show("현재 위치를 확인하지 못했어요. 잠시 후 다시 시도해 주세요", in: view)
case .chipSearchFailed:
DSToast.show("막차를 찾지 못했어요. 다시 시도해 주세요", in: view)
case .chipServiceEnded:
// 검색 화면 빈 상태 제목과 같은 어휘(Phase 18) — 화면 간 표기 통일.
DSToast.show("오늘 막차가 끊겼어요", in: view)
case .chipNoRoute:
DSToast.show("대중교통 경로를 찾지 못했어요", in: view)
case .alarmPermissionNeeded:
DSToast.show(
"알람 권한이 꺼져 있어요",
Expand Down
Loading