From 6242bf690eea148ca8d93378aca85f4bbea2926d Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 02:05:30 +0900 Subject: [PATCH 01/27] =?UTF-8?q?refactor:=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B0=94=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=EA=B3=BC=20=EB=B2=84=ED=8A=BC=20=EC=83=81=ED=98=B8?= =?UTF-8?q?=EC=9E=91=EC=9A=A9=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomNavigationBackBar를 ZStack 구조로 바꿔 제목이 정확히 중앙에 오도록 수정 - 버튼 탭 영역이 더 잘 잡히도록 contentShape 추가 - CustomNavigationBar의 불필요한 공백 제거 --- .../Ui/Navigation/CustomNavigationBar.swift | 1 - .../Sources/Ui/Navigation/NavigationBar.swift | 26 +++++++++---------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Navigation/CustomNavigationBar.swift b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/CustomNavigationBar.swift index 0704ec7..6fa890a 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Navigation/CustomNavigationBar.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/CustomNavigationBar.swift @@ -40,7 +40,6 @@ public struct CustomNavigationBar: View { Spacer() - Text(title) .pretendardCustomFont(textStyle: .titleBold) .foregroundStyle(.staticBlack) diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Navigation/NavigationBar.swift b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/NavigationBar.swift index 5ab5a28..32aa363 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Navigation/NavigationBar.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/NavigationBar.swift @@ -20,27 +20,25 @@ public struct CustomNavigationBackBar: View { } public var body: some View { - HStack { - Image(asset: .leftArrow) - .resizable() - .scaledToFit() - .frame(width: 60, height: 60) - .onTapGesture { - buttonAction() - } - - Spacer() - + ZStack { if !title.isEmpty { Text(title) .pretendardCustomFont(textStyle: .titleBold) .foregroundStyle(.staticBlack) + } + + HStack { + Image(asset: .leftArrow) + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .contentShape(Rectangle()) + .onTapGesture { + buttonAction() + } - Spacer() - } else { Spacer() } - } } } From 22b01ad2ba1a1294535f8484094966aaa2496e05 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:10:41 +0900 Subject: [PATCH 02/27] =?UTF-8?q?feat:=20add=20history=20API=20domain=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/API/Domain/TimeSpotDomain.swift | 3 +++ .../API/Sources/API/History/HistoryAPI.swift | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 Projects/Data/API/Sources/API/History/HistoryAPI.swift diff --git a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift index dd7a1b6..6f7db44 100644 --- a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift +++ b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift @@ -13,6 +13,7 @@ public enum TimeSpotDomain { case auth case place case profile + case history } extension TimeSpotDomain: DomainType { @@ -28,6 +29,8 @@ extension TimeSpotDomain: DomainType { return "api/v1/place" case .profile: return "api/v1/users" + case .history: + return "api/v1/histories" } } } diff --git a/Projects/Data/API/Sources/API/History/HistoryAPI.swift b/Projects/Data/API/Sources/API/History/HistoryAPI.swift new file mode 100644 index 0000000..cd99ee9 --- /dev/null +++ b/Projects/Data/API/Sources/API/History/HistoryAPI.swift @@ -0,0 +1,19 @@ +// +// HistoryAPI.swift +// API +// +// Created by Wonji Suh on 3/26/26. +// +import Foundation + +public enum HistoryAPI: String, CaseIterable { + case myHistory + + public var description: String { + switch self { + case .myHistory: + return "" + } + } +} + From aa244ad3879871d5d431a6219250931c64ab883a Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:12:08 +0900 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20add=20History=20data=20=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=A1=B0=ED=9A=8C=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/History/DTO/HistoryDTOModel.swift | 100 ++++++++++++++++++ .../History/Mapper/HistoryDTOModel+.swift | 42 ++++++++ .../Sources/Profile/DTO/ProfileDTO.swift | 8 +- .../Profile/Mapper/ProfileDTOModel+.swift | 4 +- 4 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift create mode 100644 Projects/Data/Model/Sources/History/Mapper/HistoryDTOModel+.swift diff --git a/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift b/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift new file mode 100644 index 0000000..84c6b6f --- /dev/null +++ b/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift @@ -0,0 +1,100 @@ +// +// HistoryDTOModel.swift +// Model +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public typealias HistoryDTOModel = BaseResponseDTO + +public struct HistoryPageResponseDTO: Decodable, Equatable { + public let content: [HistoryItemResponseDTO] + public let totalElements: Int + public let totalPages: Int + public let size: Int + public let number: Int + public let first: Bool + public let last: Bool + + public init( + content: [HistoryItemResponseDTO], + totalElements: Int, + totalPages: Int, + size: Int, + number: Int, + first: Bool, + last: Bool + ) { + self.content = content + self.totalElements = totalElements + self.totalPages = totalPages + self.size = size + self.number = number + self.first = first + self.last = last + } +} + +public struct HistoryItemResponseDTO: Decodable, Equatable { + public let visitingHistoryID: Int + public let stationID: Int + public let stationName: String + public let placeID: Int + public let placeName: String + public let placeCategory: String + public let startTime: String + public let endTime: String? + public let trainDepartureTime: String + public let totalDurationMinutes: Int + public let isInProgress: Bool + public let isSuccess: Bool + public let createdAt: String + + enum CodingKeys: String, CodingKey { + case visitingHistoryID = "visitingHistoryId" + case stationID = "stationId" + case stationName + case placeID = "placeId" + case placeName + case placeCategory + case startTime + case endTime + case trainDepartureTime + case totalDurationMinutes + case isInProgress + case isSuccess + case createdAt + } + + public init( + visitingHistoryID: Int, + stationID: Int, + stationName: String, + placeID: Int, + placeName: String, + placeCategory: String, + startTime: String, + endTime: String?, + trainDepartureTime: String, + totalDurationMinutes: Int, + isInProgress: Bool, + isSuccess: Bool, + createdAt: String + ) { + self.visitingHistoryID = visitingHistoryID + self.stationID = stationID + self.stationName = stationName + self.placeID = placeID + self.placeName = placeName + self.placeCategory = placeCategory + self.startTime = startTime + self.endTime = endTime + self.trainDepartureTime = trainDepartureTime + self.totalDurationMinutes = totalDurationMinutes + self.isInProgress = isInProgress + self.isSuccess = isSuccess + self.createdAt = createdAt + } +} diff --git a/Projects/Data/Model/Sources/History/Mapper/HistoryDTOModel+.swift b/Projects/Data/Model/Sources/History/Mapper/HistoryDTOModel+.swift new file mode 100644 index 0000000..cf27897 --- /dev/null +++ b/Projects/Data/Model/Sources/History/Mapper/HistoryDTOModel+.swift @@ -0,0 +1,42 @@ +// +// HistoryDTOModel+.swift +// Model +// +// Created by Wonji Suh on 3/26/26. +// + +import Entity + +public extension HistoryPageResponseDTO { + func toDomain() -> HistoryEntity { + HistoryEntity( + items: content.map { $0.toDomain() }, + totalElements: totalElements, + totalPages: totalPages, + size: size, + page: number + 1, + isFirstPage: first, + isLastPage: last + ) + } +} + +public extension HistoryItemResponseDTO { + func toDomain() -> HistoryItemEntity { + HistoryItemEntity( + id: visitingHistoryID, + stationID: stationID, + stationName: stationName, + placeID: placeID, + placeName: placeName, + placeCategory: placeCategory, + startTime: startTime, + endTime: endTime, + trainDepartureTime: trainDepartureTime, + totalDurationMinutes: totalDurationMinutes, + isInProgress: isInProgress, + isSuccess: isSuccess, + createdAt: createdAt + ) + } +} diff --git a/Projects/Data/Model/Sources/Profile/DTO/ProfileDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/ProfileDTO.swift index 92d8d29..ecfcfa8 100644 --- a/Projects/Data/Model/Sources/Profile/DTO/ProfileDTO.swift +++ b/Projects/Data/Model/Sources/Profile/DTO/ProfileDTO.swift @@ -13,12 +13,14 @@ public typealias ProfileDTOModel = BaseResponseDTO public struct ProfileResponseDTO: Decodable, Equatable { let userID, email, nickname, mapAPI: String let role, providerType, createdAt: String + let totalVisitCount, totalJourneyMinutes: Int enum CodingKeys: String, CodingKey { case userID = "userId" case email, nickname case mapAPI = "mapApi" case role, providerType, createdAt + case totalVisitCount, totalJourneyMinutes } public init( @@ -28,7 +30,9 @@ public struct ProfileResponseDTO: Decodable, Equatable { mapAPI: String, role: String, providerType: String, - createdAt: String + createdAt: String, + totalJourneyMinutes: Int, + totalVisitCount: Int ) { self.userID = userID self.email = email @@ -37,5 +41,7 @@ public struct ProfileResponseDTO: Decodable, Equatable { self.role = role self.providerType = providerType self.createdAt = createdAt + self.totalVisitCount = totalVisitCount + self.totalJourneyMinutes = totalJourneyMinutes } } diff --git a/Projects/Data/Model/Sources/Profile/Mapper/ProfileDTOModel+.swift b/Projects/Data/Model/Sources/Profile/Mapper/ProfileDTOModel+.swift index de84420..330c483 100644 --- a/Projects/Data/Model/Sources/Profile/Mapper/ProfileDTOModel+.swift +++ b/Projects/Data/Model/Sources/Profile/Mapper/ProfileDTOModel+.swift @@ -34,7 +34,9 @@ public extension ProfileResponseDTO { email: self.email, nickname: self.nickname, mapType: mapType, - provider: provider + provider: provider, + totalVisitCount: totalVisitCount, + totalJourneyMinutes: totalJourneyMinutes ) } } From baa54bd31a4be3d433423e4e77aa9c4fb7319ade Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:12:58 +0900 Subject: [PATCH 04/27] =?UTF-8?q?feat:=20add=20History=20repository=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20Profile=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../History/HistoryRepositoryImpl.swift | 40 +++++++++++++++++++ .../Profile/ProfileRepositoryImpl.swift | 3 +- 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 Projects/Data/Repository/Sources/History/HistoryRepositoryImpl.swift diff --git a/Projects/Data/Repository/Sources/History/HistoryRepositoryImpl.swift b/Projects/Data/Repository/Sources/History/HistoryRepositoryImpl.swift new file mode 100644 index 0000000..916a494 --- /dev/null +++ b/Projects/Data/Repository/Sources/History/HistoryRepositoryImpl.swift @@ -0,0 +1,40 @@ +// +// HistoryRepositoryImpl.swift +// Repository +// +// Created by Wonji Suh on 3/26/26. +// + +import DomainInterface +import Model +import Entity + +import Service +import Dependencies +import LogMacro + +import AsyncMoya + +public class HistoryRepositoryImpl: HistoryInterface , @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized, + ) { + self.provider = provider + } + + // MARK: - 내 히스토리 정보 + + public func myHistory( + page: Int, + size: Int, + sort: TravelHistorySort + ) async throws -> HistoryEntity { + let safePage = max(page, 1) + let body: MyHistoryRequest = .init(page: safePage, size: size, sort: sort.description) + let dto: HistoryDTOModel = try await provider.request(.myHistory(body: body)) + return dto.data.toDomain() + } + +} diff --git a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift index 0075ac1..179f0b7 100644 --- a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift @@ -76,10 +76,9 @@ public class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable { } public func editUser( - name: String, mapType: ExternalMapType ) async throws -> LoginEntity { - let body: ProfileRequest = ProfileRequest(nickname: name, mapApi: mapType.type) + let body: ProfileRequest = ProfileRequest(mapApi: mapType.type) let dto: LoginDTOModel = try await provider.request(.editProfile(body: body)) return dto.data.toDomain() } From 4192eba00ba9de5284892fac4af225d17d01bfdb Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:17:37 +0900 Subject: [PATCH 05/27] =?UTF-8?q?feat:=20add=20History=20service=20layer?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20Profile=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EB=AA=A8=EB=8D=B8=20=EC=88=98=EC=A0=95=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/History/HistoryRequest.swift | 25 ++++++++ .../Sources/History/HistoryService.swift | 58 +++++++++++++++++++ .../Sources/Profile/ProfileRequest.swift | 3 - 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 Projects/Data/Service/Sources/History/HistoryRequest.swift create mode 100644 Projects/Data/Service/Sources/History/HistoryService.swift diff --git a/Projects/Data/Service/Sources/History/HistoryRequest.swift b/Projects/Data/Service/Sources/History/HistoryRequest.swift new file mode 100644 index 0000000..d3ccb06 --- /dev/null +++ b/Projects/Data/Service/Sources/History/HistoryRequest.swift @@ -0,0 +1,25 @@ +// +// HistoryRequest.swift +// Service +// +// Created by Wonji Suh on 3/26/26. +// + + +import Foundation + +public struct MyHistoryRequest: Encodable { + public let page: Int + public let size: Int + public let sort: String + + public init( + page: Int, + size: Int, + sort: String + ) { + self.page = page + self.size = size + self.sort = sort + } +} diff --git a/Projects/Data/Service/Sources/History/HistoryService.swift b/Projects/Data/Service/Sources/History/HistoryService.swift new file mode 100644 index 0000000..1f08882 --- /dev/null +++ b/Projects/Data/Service/Sources/History/HistoryService.swift @@ -0,0 +1,58 @@ +// +// HistoryService.swift +// Service +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum HistoryService { + case myHistory(body: MyHistoryRequest) +} + + +extension HistoryService: BaseTargetType { + public typealias Domain = TimeSpotDomain + + public var domain: TimeSpotDomain { + return .history + } + + public var urlPath: String { + switch self { + case .myHistory: + return HistoryAPI.myHistory.description + } + } + + public var error: [Int : NetworkError]? { + return nil + } + + public var method: Moya.Method { + switch self { + case .myHistory: + return .get + } + } + + public var parameters: [String : Any]? { + switch self { + case .myHistory(let body): + return body.toDictionary + } + } + + public var headers: [String : String]? { + switch self { + default: + return APIHeader.baseHeader + } + } +} diff --git a/Projects/Data/Service/Sources/Profile/ProfileRequest.swift b/Projects/Data/Service/Sources/Profile/ProfileRequest.swift index 1551597..1758a4e 100644 --- a/Projects/Data/Service/Sources/Profile/ProfileRequest.swift +++ b/Projects/Data/Service/Sources/Profile/ProfileRequest.swift @@ -6,14 +6,11 @@ // public struct ProfileRequest: Encodable { - public let nickname: String public let mapApi: String public init( - nickname: String, mapApi: String ) { - self.nickname = nickname self.mapApi = mapApi } } From 12331e9045c031766fad487f072557c59951435a Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:18:34 +0900 Subject: [PATCH 06/27] =?UTF-8?q?feat:=20add=20History=20repository=20inte?= =?UTF-8?q?rface=20=EB=B0=8F=20Profile=20=EC=88=98=EC=A0=95=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultHistoryRepositoryImpl.swift | 62 +++++++++++++++++++ .../Sources/History/HistoryInterface.swift | 37 +++++++++++ .../DefaultProfileRepositoryImpl.swift | 7 ++- .../Sources/Profile/ProfileInterface.swift | 1 - 4 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 Projects/Domain/DomainInterface/Sources/History/DefaultHistoryRepositoryImpl.swift create mode 100644 Projects/Domain/DomainInterface/Sources/History/HistoryInterface.swift diff --git a/Projects/Domain/DomainInterface/Sources/History/DefaultHistoryRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/History/DefaultHistoryRepositoryImpl.swift new file mode 100644 index 0000000..a98639d --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/History/DefaultHistoryRepositoryImpl.swift @@ -0,0 +1,62 @@ +// +// DefaultHistoryRepositoryImpl.swift +// DomainInterface +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation +import Entity + +final public class DefaultHistoryRepositoryImpl: HistoryInterface { + public init() {} + + public func myHistory( + page: Int, + size: Int, + sort: TravelHistorySort + ) async throws -> HistoryEntity { + let items: [HistoryItemEntity] = [ + .init( + id: 1, + stationID: 10, + stationName: "서울역", + placeID: 100, + placeName: "스타벅스 서울역점", + placeCategory: "카페", + startTime: "2024-03-25T13:00:00", + endTime: "2024-03-25T14:30:00", + trainDepartureTime: "2024-03-25T15:30:00", + totalDurationMinutes: 90, + isInProgress: false, + isSuccess: true, + createdAt: "2024-03-25T13:00:00" + ), + .init( + id: 2, + stationID: 20, + stationName: "강남역", + placeID: 200, + placeName: "강남역 맛집", + placeCategory: "레스토랑", + startTime: "2024-03-24T10:00:00", + endTime: nil, + trainDepartureTime: "2024-03-24T12:00:00", + totalDurationMinutes: 0, + isInProgress: true, + isSuccess: false, + createdAt: "2024-03-24T10:00:00" + ) + ] + + return HistoryEntity( + items: items, + totalElements: items.count, + totalPages: 1, + size: size, + page: page, + isFirstPage: page == 1, + isLastPage: true + ) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/History/HistoryInterface.swift b/Projects/Domain/DomainInterface/Sources/History/HistoryInterface.swift new file mode 100644 index 0000000..5c10082 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/History/HistoryInterface.swift @@ -0,0 +1,37 @@ +// +// HistoryInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 3/26/26. +// + +import Entity +import ComposableArchitecture +import WeaveDI + +public protocol HistoryInterface: Sendable { + func myHistory( + page: Int, + size: Int, + sort: TravelHistorySort + ) async throws -> HistoryEntity +} + +public struct HistoryRepositoryDependency: DependencyKey { + public static var liveValue: HistoryInterface { + UnifiedDI.resolve(HistoryInterface.self) ?? DefaultHistoryRepositoryImpl() + } + + public static var testValue: HistoryInterface { + UnifiedDI.resolve(HistoryInterface.self) ?? DefaultHistoryRepositoryImpl() + } + + public static var previewValue: HistoryInterface = liveValue +} + +public extension DependencyValues { + var historyRepository: HistoryInterface { + get { self[HistoryRepositoryDependency.self] } + set { self[HistoryRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift index 984eabe..ad79a8e 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift @@ -17,16 +17,17 @@ final public class DefaultProfileRepositoryImpl: ProfileInterface { email: "test@example.com", nickname: "Mock User", mapType: .appleMap, - provider: .apple + provider: .apple, + totalVisitCount: 24, + totalJourneyMinutes: 320 ) } public func editUser( - name: String, mapType: ExternalMapType ) async throws -> LoginEntity { return LoginEntity( - name: name, + name: "테스터", isNewUser: false, provider: .apple, token: AuthTokens( diff --git a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift index 288da23..277c622 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift @@ -12,7 +12,6 @@ import WeaveDI public protocol ProfileInterface: Sendable { func fetchUser() async throws -> ProfileEntity func editUser( - name: String, mapType: ExternalMapType ) async throws -> LoginEntity } From 5a5c86b70fd11febc7c488ae51f83e99c7c3f177 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:20:31 +0900 Subject: [PATCH 07/27] =?UTF-8?q?=20feat:=20History=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20Profile?= =?UTF-8?q?=20=ED=86=B5=EA=B3=84=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실제 서버 데이터 연동으로 더미 데이터 제거 - 로딩 상태 및 빈 상태 UI 개선 - 페이지네이션으로 성능 최적화 - 시간 포맷팅으로 가독성 향상 --- .../Sources/History/HistoryEntity.swift | 92 +++++++++++++++++++ .../History/TravelHistoryCardItem.swift | 31 +++++++ .../Sources/Profile/ProfileEntity.swift | 27 +++++- .../Sources/Profile/TravelHistorySort.swift | 9 ++ .../Entity/Sources/TrainStation/Station.swift | 48 ++++++++++ 5 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 Projects/Domain/Entity/Sources/History/HistoryEntity.swift create mode 100644 Projects/Domain/Entity/Sources/History/TravelHistoryCardItem.swift create mode 100644 Projects/Domain/Entity/Sources/TrainStation/Station.swift diff --git a/Projects/Domain/Entity/Sources/History/HistoryEntity.swift b/Projects/Domain/Entity/Sources/History/HistoryEntity.swift new file mode 100644 index 0000000..7d814e0 --- /dev/null +++ b/Projects/Domain/Entity/Sources/History/HistoryEntity.swift @@ -0,0 +1,92 @@ +// +// HistoryEntity.swift +// Entity +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public struct HistoryEntity: Equatable, Hashable { + public let items: [HistoryItemEntity] + public let totalElements: Int + public let totalPages: Int + public let size: Int + public let page: Int + public let isFirstPage: Bool + public let isLastPage: Bool + + public init( + items: [HistoryItemEntity], + totalElements: Int, + totalPages: Int, + size: Int, + page: Int, + isFirstPage: Bool, + isLastPage: Bool + ) { + self.items = items + self.totalElements = totalElements + self.totalPages = totalPages + self.size = size + self.page = page + self.isFirstPage = isFirstPage + self.isLastPage = isLastPage + } +} + +public extension HistoryEntity { + var hasNextPage: Bool { + !isLastPage && page < totalPages + } + + var nextPage: Int? { + hasNextPage ? page + 1 : nil + } +} + +public struct HistoryItemEntity: Equatable, Hashable, Identifiable { + public let id: Int + public let stationID: Int + public let stationName: String + public let placeID: Int + public let placeName: String + public let placeCategory: String + public let startTime: String + public let endTime: String? + public let trainDepartureTime: String + public let totalDurationMinutes: Int + public let isInProgress: Bool + public let isSuccess: Bool + public let createdAt: String + + public init( + id: Int, + stationID: Int, + stationName: String, + placeID: Int, + placeName: String, + placeCategory: String, + startTime: String, + endTime: String?, + trainDepartureTime: String, + totalDurationMinutes: Int, + isInProgress: Bool, + isSuccess: Bool, + createdAt: String + ) { + self.id = id + self.stationID = stationID + self.stationName = stationName + self.placeID = placeID + self.placeName = placeName + self.placeCategory = placeCategory + self.startTime = startTime + self.endTime = endTime + self.trainDepartureTime = trainDepartureTime + self.totalDurationMinutes = totalDurationMinutes + self.isInProgress = isInProgress + self.isSuccess = isSuccess + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/History/TravelHistoryCardItem.swift b/Projects/Domain/Entity/Sources/History/TravelHistoryCardItem.swift new file mode 100644 index 0000000..85a07a7 --- /dev/null +++ b/Projects/Domain/Entity/Sources/History/TravelHistoryCardItem.swift @@ -0,0 +1,31 @@ +// +// TravelHistoryCardItem.swift +// Entity +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public struct TravelHistoryCardItem: Identifiable, Hashable { + public let id = UUID() + public let visitedAt: Date + public let placeName: String + public let departureName: String + public let durationText: String + public let departureTimeText: String + + public init( + visitedAt: Date, + placeName: String, + departureName: String, + durationText: String, + departureTimeText: String + ) { + self.visitedAt = visitedAt + self.placeName = placeName + self.departureName = departureName + self.durationText = durationText + self.departureTimeText = departureTimeText + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/ProfileEntity.swift b/Projects/Domain/Entity/Sources/Profile/ProfileEntity.swift index 9a183be..750ef0c 100644 --- a/Projects/Domain/Entity/Sources/Profile/ProfileEntity.swift +++ b/Projects/Domain/Entity/Sources/Profile/ProfileEntity.swift @@ -12,16 +12,41 @@ public struct ProfileEntity: Equatable, Hashable { public let email, nickname: String public let mapType: ExternalMapType public let provider: SocialType + public let totalVisitCount, totalJourneyMinutes: Int public init( email: String, nickname: String, mapType: ExternalMapType, - provider: SocialType + provider: SocialType, + totalVisitCount: Int, + totalJourneyMinutes: Int ) { self.email = email self.nickname = nickname self.mapType = mapType self.provider = provider + self.totalVisitCount = totalVisitCount + self.totalJourneyMinutes = totalJourneyMinutes + } +} + +// MARK: - Computed Properties +extension ProfileEntity { + /// totalJourneyMinutes를 "00시간00분" 형태로 포맷팅 + public var formattedJourneyTime: String { + let hours = totalJourneyMinutes / 60 + let minutes = totalJourneyMinutes % 60 + return String(format: "%02d시간%02d분", hours, minutes) + } + + /// 시간만 필요한 경우 + public var journeyHours: Int { + return totalJourneyMinutes / 60 + } + + /// 분만 필요한 경우 (시간 제외한 나머지 분) + public var journeyMinutes: Int { + return totalJourneyMinutes % 60 } } diff --git a/Projects/Domain/Entity/Sources/Profile/TravelHistorySort.swift b/Projects/Domain/Entity/Sources/Profile/TravelHistorySort.swift index 7f418fb..fe30583 100644 --- a/Projects/Domain/Entity/Sources/Profile/TravelHistorySort.swift +++ b/Projects/Domain/Entity/Sources/Profile/TravelHistorySort.swift @@ -19,4 +19,13 @@ public enum TravelHistorySort: String, CaseIterable, Equatable, Hashable { return "오래된 순" } } + + public var description: String { + switch self { + case .oldest: + return "createdAt,ASC" + case .recent: + return "createdAt,DESC" + } + } } diff --git a/Projects/Domain/Entity/Sources/TrainStation/Station.swift b/Projects/Domain/Entity/Sources/TrainStation/Station.swift new file mode 100644 index 0000000..25e635f --- /dev/null +++ b/Projects/Domain/Entity/Sources/TrainStation/Station.swift @@ -0,0 +1,48 @@ +// +// Station.swift +// Entity +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { + case seoul + case yongsan + case busan + case dongdaegu + case daejeon + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .seoul: + return "서울" + case .yongsan: + return "용산" + case .busan: + return "부산" + case .dongdaegu: + return "동대구" + case .daejeon: + return "대전" + } + } + + public var homeTitle: String { + switch self { + case .seoul: + return "SEOUL" + case .yongsan: + return "YONGSAN" + case .busan: + return "BUSAN" + case .dongdaegu: + return "DONGDAEGU" + case .daejeon: + return "DAEJEON" + } + } +} From e4d15ff1c6b8f7330a9a875236df812c22e90753 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:21:22 +0900 Subject: [PATCH 08/27] =?UTF-8?q?feat:=20add=20HistoryUseCaseImpl=20?= =?UTF-8?q?=EB=B0=8F=20Profile=20=EC=88=98=EC=A0=95=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/History/HistoryUseCaseImpl.swift | 41 +++++++++++++++++++ .../Sources/Profile/ProfileUseCaseImpl.swift | 3 +- 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 Projects/Domain/UseCase/Sources/History/HistoryUseCaseImpl.swift diff --git a/Projects/Domain/UseCase/Sources/History/HistoryUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/History/HistoryUseCaseImpl.swift new file mode 100644 index 0000000..e022091 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/History/HistoryUseCaseImpl.swift @@ -0,0 +1,41 @@ +// +// HistoryUseCaseImpl.swift +// UseCase +// +// Created by Wonji Suh on 3/26/26. +// + +import DomainInterface +import Entity + +import WeaveDI +import ComposableArchitecture + +public struct HistoryUseCaseImpl: HistoryInterface { + @Dependency(\.historyRepository) var repository + + public init() {} + + // MARK: - 히스토리 정보 + public func myHistory( + page: Int, + size: Int, + sort: TravelHistorySort + ) async throws -> HistoryEntity { + return try await repository.myHistory(page: page, size: size, sort: sort) + } +} + + +extension HistoryUseCaseImpl: DependencyKey { + static public var liveValue: HistoryInterface = HistoryUseCaseImpl() + static public var testValue: HistoryInterface = HistoryUseCaseImpl() + static public var previewValue: HistoryInterface = HistoryUseCaseImpl() +} + +public extension DependencyValues { + var historyUseCase: HistoryInterface { + get { self[HistoryUseCaseImpl.self] } + set { self[HistoryUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCaseImpl.swift index 89e5a53..0217d44 100644 --- a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCaseImpl.swift @@ -26,10 +26,9 @@ public struct ProfileUseCaseImpl: ProfileInterface { } public func editUser( - name: String, mapType: ExternalMapType ) async throws -> LoginEntity { - let editUserEntity = try await repository.editUser(name: name, mapType: mapType) + let editUserEntity = try await repository.editUser(mapType: mapType) do { try await keychainManager.save( From cdf1b47b470bbad858eab4e9705feb090904f243 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:22:54 +0900 Subject: [PATCH 09/27] =?UTF-8?q?feat:=20add=20TrainStation=20=EC=97=AD=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20LocationM?= =?UTF-8?q?anager=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20#11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/LocationPermissionManager.swift | 220 -------------- .../TrainStation/Model/StationRowModel.swift | 17 ++ .../Reducer/TrainStationFeature.swift | 142 ++++++++++ .../TrainStation/View/TrainStationView.swift | 268 ++++++++++++++++++ 4 files changed, 427 insertions(+), 220 deletions(-) delete mode 100644 Projects/Presentation/Home/Sources/LocationPermissionManager.swift create mode 100644 Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift create mode 100644 Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift create mode 100644 Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift diff --git a/Projects/Presentation/Home/Sources/LocationPermissionManager.swift b/Projects/Presentation/Home/Sources/LocationPermissionManager.swift deleted file mode 100644 index 449a315..0000000 --- a/Projects/Presentation/Home/Sources/LocationPermissionManager.swift +++ /dev/null @@ -1,220 +0,0 @@ -// -// LocationPermissionManager.swift -// Home -// -// Created by Roy on 2026-03-11 -// Copyright © 2026 TimeSpot, Ltd., All rights reserved. -// - -import Foundation -import CoreLocation - -#if canImport(UIKit) -import UIKit -#endif - -// MARK: - Location Errors -public enum LocationError: Error, LocalizedError { - case permissionDenied - case locationUnavailable - case timeout - - public var errorDescription: String? { - switch self { - case .permissionDenied: - return "위치 권한이 거부되었습니다." - case .locationUnavailable: - return "위치 정보를 가져올 수 없습니다." - case .timeout: - return "위치 요청 시간이 초과되었습니다." - } - } -} - -// Swift Concurrency를 사용한 위치 권한 전용 관리자 -@MainActor -public final class LocationPermissionManager: NSObject, ObservableObject { - - // 싱글톤 인스턴스 - public static let shared = LocationPermissionManager() - @Published public private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined - @Published public private(set) var currentLocation: CLLocation? - @Published public private(set) var locationError: String? - - private let locationManager = CLLocationManager() - private var authorizationContinuation: CheckedContinuation? - private var locationContinuation: CheckedContinuation? - - // 지속적인 위치 업데이트 콜백 (MainActor 격리) - @MainActor - public var onLocationUpdate: (@MainActor (CLLocation) -> Void)? - @MainActor - public var onLocationError: (@MainActor (Error) -> Void)? - - public override init() { - super.init() - setupLocationManager() - } - - private func setupLocationManager() { - locationManager.delegate = self - locationManager.desiredAccuracy = kCLLocationAccuracyBest - locationManager.distanceFilter = 10 // 10미터 이상 이동시 업데이트 - authorizationStatus = locationManager.authorizationStatus - } - - // async/await을 사용한 위치 권한 요청 - public func requestLocationPermission() async -> CLAuthorizationStatus { - guard CLLocationManager.locationServicesEnabled() else { - locationError = "위치 서비스가 비활성화되어 있습니다. 설정에서 활성화해 주세요." - return .denied - } - - // authorizationStatus는 델리게이트에서 업데이트된 값 사용 - switch authorizationStatus { - case .notDetermined: - return await withCheckedContinuation { continuation in - self.authorizationContinuation = continuation - locationManager.requestWhenInUseAuthorization() - } - case .denied, .restricted: - locationError = "위치 권한이 거부되었습니다. 설정에서 허용해 주세요." - return authorizationStatus - case .authorizedWhenInUse, .authorizedAlways: - return authorizationStatus - @unknown default: - locationError = "알 수 없는 위치 권한 상태입니다." - return authorizationStatus - } - } - - // iOS 14+ 정확한 위치 권한 요청 - public func requestFullAccuracy() { - if #available(iOS 14.0, *) { - locationManager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "TimeSpotLocationAccuracy") - } - } - - // 위치 업데이트 시작 - public func startLocationUpdates() { - guard authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways else { - locationError = "위치 권한이 없습니다." - return - } - - locationManager.startUpdatingLocation() - } - - // 위치 업데이트 중지 - public func stopLocationUpdates() { - locationManager.stopUpdatingLocation() - } - - // async/await을 사용한 현재 위치 가져오기 - public func requestCurrentLocation() async throws -> CLLocation? { - guard authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways else { - locationError = "위치 권한이 없습니다." - throw LocationError.permissionDenied - } - - return try await withCheckedThrowingContinuation { continuation in - self.locationContinuation = continuation - - if #available(iOS 14.0, *) { - locationManager.requestLocation() - } else { - // iOS 14 이전에서는 잠시 업데이트하고 중지 - locationManager.startUpdatingLocation() - Task { - try await Task.sleep(for: .seconds(3)) - self.stopLocationUpdates() - } - } - } - } - - // 설정 앱으로 이동 - public func openLocationSettings() { - #if canImport(UIKit) - Task { @MainActor in - if let settingsUrl = URL(string: UIApplication.openSettingsURLString), - UIApplication.shared.canOpenURL(settingsUrl) { - await UIApplication.shared.open(settingsUrl) - } - } - #endif - } - - // 위치 서비스 사용 가능 여부 - public var isLocationServicesEnabled: Bool { - CLLocationManager.locationServicesEnabled() - } - - // 권한 상태 문자열 - public var authorizationStatusString: String { - switch authorizationStatus { - case .notDetermined: - return "권한 미결정" - case .restricted: - return "권한 제한됨" - case .denied: - return "권한 거부됨" - case .authorizedAlways: - return "항상 허용" - case .authorizedWhenInUse: - return "사용 중 허용" - @unknown default: - return "알 수 없음" - } - } -} - -// MARK: - CLLocationManagerDelegate -extension LocationPermissionManager: CLLocationManagerDelegate { - - nonisolated public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let location = locations.last else { return } - - Task { @MainActor in - self.currentLocation = location - self.locationError = nil - - // 지속적인 위치 업데이트 콜백 호출 - await self.onLocationUpdate?(location) - - // continuation이 있으면 결과 반환 (일회성 요청용) - if let continuation = self.locationContinuation { - self.locationContinuation = nil - continuation.resume(returning: location) - } - } - } - - nonisolated public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - Task { @MainActor in - self.locationError = "위치 업데이트 실패: \(error.localizedDescription)" - - // 지속적인 위치 업데이트 에러 콜백 호출 - await self.onLocationError?(error) - - // continuation이 있으면 에러 반환 (일회성 요청용) - if let continuation = self.locationContinuation { - self.locationContinuation = nil - continuation.resume(throwing: error) - } - } - } - - nonisolated public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { - Task { @MainActor in - self.authorizationStatus = status - self.locationError = nil - - // continuation이 있으면 권한 상태 반환 - if let continuation = self.authorizationContinuation { - self.authorizationContinuation = nil - continuation.resume(returning: status) - } - } - } -} \ No newline at end of file diff --git a/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift b/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift new file mode 100644 index 0000000..6214db6 --- /dev/null +++ b/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift @@ -0,0 +1,17 @@ +// +// StationRowModel.swift +// Home +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation +import Entity + +public struct StationRowModel: Identifiable, Equatable { + public let id: String + let station: Station + let badges: [String] + let distanceText: String? + let isFavorite: Bool +} diff --git a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift new file mode 100644 index 0000000..dcf4f63 --- /dev/null +++ b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift @@ -0,0 +1,142 @@ +// +// TrainStationFeature.swift +// Home +// +// Created by Wonji Suh on 3/26/26. +// + + +import Foundation +import ComposableArchitecture + +import DomainInterface +import Utill +import Entity + +@Reducer +public struct TrainStationFeature { + @Dependency(\.keychainManager) var keychainManager + + public init() {} + + @ObservableState + public struct State: Equatable { + var searchText: String = "" + var shouldShowFavoriteSection: Bool = false + var selectedStation: Station + + public init(selectedStation: Station = .seoul) { + self.selectedStation = selectedStation + } + } + + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + + } + + //MARK: - ViewAction + @CasePathable + public enum View { + case onAppear + case stationTapped(Station) + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + case checkAccessToken + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + case accessTokenChecked(Bool) + } + + //MARK: - DelegateAction + public enum DelegateAction: Equatable { + case stationSelected(Station) + } + + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(_): + return .none + + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .delegate(let delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension TrainStationFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.checkAccessToken)) + + case .stationTapped(let station): + state.selectedStation = station + return .send(.delegate(.stationSelected(station))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .checkAccessToken: + return .run { [keychainManager] send in + let accessToken = await keychainManager.accessToken() + let hasAccessToken = !(accessToken?.isEmpty ?? true) + await send(.inner(.accessTokenChecked(hasAccessToken))) + } + } + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + default: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case .accessTokenChecked(let shouldShowFavoriteSection): + state.shouldShowFavoriteSection = shouldShowFavoriteSection + return .none + } + } +} + +extension TrainStationFeature.State: Hashable {} diff --git a/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift b/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift new file mode 100644 index 0000000..a9e8d2f --- /dev/null +++ b/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift @@ -0,0 +1,268 @@ +// +// TrainStationView.swift +// Home +// +// Created by Wonji Suh on 3/26/26. +// + +import SwiftUI + +import DesignSystem +import Entity + +import ComposableArchitecture + +public struct TrainStationView: View { + @Environment(\.modalDismiss) private var modalDismiss + @Bindable var store: StoreOf + + public init( + store: StoreOf + ) { + self.store = store + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + headerView() + searchFieldView() + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if store.shouldShowFavoriteSection { + stationSectionView( + title: "즐겨찾기", + systemIcon: "star.fill", + assetIcon: nil, + iconColor: .gray550, + stations: filteredFavoriteStations + ) + } + + stationSectionView( + title: "가까운 역", + systemIcon: nil, + assetIcon: .mapSharp, + iconColor: .gray550, + stations: filteredNearbyStations + ) + + stationSectionView( + title: "주요 역", + systemIcon: nil, + assetIcon: .subway, + iconColor: .gray550, + stations: filteredMajorStations + ) + } + .padding(.bottom, 16) + } + .scrollIndicators(.hidden) + } + .background(.staticWhite) + .onAppear { + store.send(.view(.onAppear)) + } + } +} + +extension TrainStationView { + private var filteredFavoriteStations: [StationRowModel] { + filterRows(favoriteStations) + } + + private var filteredNearbyStations: [StationRowModel] { + filterRows(nearbyStations) + } + + private var filteredMajorStations: [StationRowModel] { + filterRows(majorStations) + } + + private func filterRows(_ rows: [StationRowModel]) -> [StationRowModel] { + guard !store.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return rows + } + + return rows.filter { + $0.station.displayName.localizedCaseInsensitiveContains(store.searchText) + || $0.badges.joined(separator: " ").localizedCaseInsensitiveContains(store.searchText) + } + } + + @ViewBuilder + private func headerView() -> some View { + Text("출발역 선택") + .pretendardCustomFont(textStyle: .titleBold) + .foregroundStyle(.gray900) + .padding(.top, 34) + .padding(.horizontal, 20) + .padding(.bottom, 22) + } + + @ViewBuilder + private func searchFieldView() -> some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.gray500) + + ZStack(alignment: .leading) { + if store.searchText.isEmpty { + Text("역명을 입력해주세요.") + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray700) + } + + TextField("", text: $store.searchText) + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray900) + } + } + .padding(.horizontal, 20) + .frame(height: 60) + .background(.gray200) + .clipShape(RoundedRectangle(cornerRadius: 18)) + .padding(.horizontal, 20) + .padding(.bottom, 14) + } + + @ViewBuilder + private func stationSectionView( + title: String, + systemIcon: String?, + assetIcon: ImageAsset?, + iconColor: Color, + stations: [StationRowModel] + ) -> some View { + if !stations.isEmpty { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + if let assetIcon { + Image(asset: assetIcon) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + } else if let systemIcon { + Image(systemName: systemIcon) + .frame(width: 16, height: 16) + .foregroundStyle(iconColor) + } + + Text(title) + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray700) + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 16) + + Rectangle() + .fill(.gray200) + .frame(height: 1) + .padding(.leading, 20) + .padding(.trailing, 24) + + LazyVStack(spacing: 0) { + ForEach(Array(stations.enumerated()), id: \.element.id) { index, row in + VStack(spacing: 0) { + stationRowView(row) + + if title != "주요 역" || index < stations.count - 1 { + Rectangle() + .fill(.gray200) + .frame(height: 1) + .padding(.leading, 20) + .padding(.trailing, 24) + } + } + } + } + } + } + } + + @ViewBuilder + private func stationRowView(_ row: StationRowModel) -> some View { + Button { + store.send(.view(.stationTapped(row.station))) + modalDismiss() + } label: { + HStack(spacing: 12) { + HStack(spacing: 8) { + Text(row.station.displayName) + .pretendardCustomFont(textStyle: .titleRegular) + .foregroundStyle(.staticBlack) + + HStack(spacing: 8) { + ForEach(row.badges, id: \.self) { badge in + badgeView(badge) + } + } + } + + Spacer() + + if let distance = row.distanceText { + Text(distance) + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.gray500) + } + + Image(systemName: row.isFavorite ? "star.fill" : "star") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(row.isFavorite ? .orange700 : .gray550) + .frame(width: 20, height: 20) + } + .padding(.leading, 20) + .padding(.trailing, 24) + .padding(.vertical, 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func badgeView(_ title: String) -> some View { + Text(title) + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.gray700) + .padding(.horizontal, 6) + .frame(width: 48, height: 21) + .background(.gray200) + .clipShape(Capsule()) + } +} + +private extension TrainStationView { + var favoriteStations: [StationRowModel] { + [ + .init(id: "favorite-dongdaegu-1", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: true), + .init(id: "favorite-dongdaegu-2", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: true) + ] + } + + var nearbyStations: [StationRowModel] { + [ + .init(id: "nearby-dongdaegu-1", station: Station.dongdaegu, badges: ["경부선"], distanceText: "2.3km", isFavorite: false), + .init(id: "nearby-dongdaegu-2", station: Station.dongdaegu, badges: ["경부선"], distanceText: "2.3km", isFavorite: false) + ] + } + + var majorStations: [StationRowModel] { + [ + .init(id: "major-dongdaegu-1", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), + .init(id: "major-dongdaegu-2", station: Station.dongdaegu, badges: ["경부선", "경전선"], distanceText: nil, isFavorite: false), + .init(id: "major-dongdaegu-3", station: Station.dongdaegu, badges: ["경부선", "강릉선"], distanceText: nil, isFavorite: false), + .init(id: "major-dongdaegu-4", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), + .init(id: "major-dongdaegu-5", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), + .init(id: "major-dongdaegu-6", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), + .init(id: "major-dongdaegu-7", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), + .init(id: "major-dongdaegu-8", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), + .init(id: "major-dongdaegu-9", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), + .init(id: "major-dongdaegu-10", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), + .init(id: "major-dongdaegu-11", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false) + ] + } +} From d9436a5f112b02df5444c1a6a1847403bb54a008 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:26:58 +0900 Subject: [PATCH 10/27] =?UTF-8?q?feat:=20async/await=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=20LocationPermissionManager=20=EC=B6=94=EA=B0=80=20=20=20#10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swift Concurrency 권한 요청 지원 - 에러 핸들링 포함 현재 위치 가져오기 - 콜백 기반 지속적 위치 업데이트 - 설정 이동 및 권한 상태 추적 기능 --- .../Manager/LocationPermissionManager.swift | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift diff --git a/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift b/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift new file mode 100644 index 0000000..7168b09 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift @@ -0,0 +1,224 @@ +// +// LocationPermissionManager.swift +// Home +// +// Created by Roy on 2026-03-11 +// Copyright © 2026 TimeSpot, Ltd., All rights reserved. +// + +import Foundation +import CoreLocation + +#if canImport(UIKit) +import UIKit +#endif + +// MARK: - Location Errors +public enum LocationError: Error, LocalizedError { + case permissionDenied + case locationUnavailable + case timeout + + public var errorDescription: String? { + switch self { + case .permissionDenied: + return "위치 권한이 거부되었습니다." + case .locationUnavailable: + return "위치 정보를 가져올 수 없습니다." + case .timeout: + return "위치 요청 시간이 초과되었습니다." + } + } +} + +// Swift Concurrency를 사용한 위치 권한 전용 관리자 +@MainActor +public final class LocationPermissionManager: NSObject, ObservableObject { + + // 싱글톤 인스턴스 + public static let shared = LocationPermissionManager() + @Published public private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined + @Published public private(set) var currentLocation: CLLocation? + @Published public private(set) var locationError: String? + + private let locationManager = CLLocationManager() + private var authorizationContinuation: CheckedContinuation? + private var locationContinuation: CheckedContinuation? + + // 지속적인 위치 업데이트 콜백 (MainActor 격리) + @MainActor + public var onLocationUpdate: (@MainActor (CLLocation) -> Void)? + @MainActor + public var onLocationError: (@MainActor (Error) -> Void)? + + public override init() { + super.init() + setupLocationManager() + } + + private func setupLocationManager() { + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBest + locationManager.distanceFilter = 10 // 10미터 이상 이동시 업데이트 + authorizationStatus = locationManager.authorizationStatus + } + + // async/await을 사용한 위치 권한 요청 + public func requestLocationPermission() async -> CLAuthorizationStatus { + let isLocationServicesEnabled = await Task.detached { + CLLocationManager.locationServicesEnabled() + }.value + + guard isLocationServicesEnabled else { + locationError = "위치 서비스가 비활성화되어 있습니다. 설정에서 활성화해 주세요." + return .denied + } + + // authorizationStatus는 델리게이트에서 업데이트된 값 사용 + switch authorizationStatus { + case .notDetermined: + return await withCheckedContinuation { continuation in + self.authorizationContinuation = continuation + locationManager.requestWhenInUseAuthorization() + } + case .denied, .restricted: + locationError = "위치 권한이 거부되었습니다. 설정에서 허용해 주세요." + return authorizationStatus + case .authorizedWhenInUse, .authorizedAlways: + return authorizationStatus + @unknown default: + locationError = "알 수 없는 위치 권한 상태입니다." + return authorizationStatus + } + } + + // iOS 14+ 정확한 위치 권한 요청 + public func requestFullAccuracy() { + if #available(iOS 14.0, *) { + locationManager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "TimeSpotLocationAccuracy") + } + } + + // 위치 업데이트 시작 + public func startLocationUpdates() { + guard authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways else { + locationError = "위치 권한이 없습니다." + return + } + + locationManager.startUpdatingLocation() + } + + // 위치 업데이트 중지 + public func stopLocationUpdates() { + locationManager.stopUpdatingLocation() + } + + // async/await을 사용한 현재 위치 가져오기 + public func requestCurrentLocation() async throws -> CLLocation? { + guard authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways else { + locationError = "위치 권한이 없습니다." + throw LocationError.permissionDenied + } + + return try await withCheckedThrowingContinuation { continuation in + self.locationContinuation = continuation + + if #available(iOS 14.0, *) { + locationManager.requestLocation() + } else { + // iOS 14 이전에서는 잠시 업데이트하고 중지 + locationManager.startUpdatingLocation() + Task { + try await Task.sleep(for: .seconds(3)) + self.stopLocationUpdates() + } + } + } + } + + // 설정 앱으로 이동 + public func openLocationSettings() { + #if canImport(UIKit) + Task { @MainActor in + if let settingsUrl = URL(string: UIApplication.openSettingsURLString), + UIApplication.shared.canOpenURL(settingsUrl) { + await UIApplication.shared.open(settingsUrl) + } + } + #endif + } + + // 위치 서비스 사용 가능 여부 + public var isLocationServicesEnabled: Bool { + CLLocationManager.locationServicesEnabled() + } + + // 권한 상태 문자열 + public var authorizationStatusString: String { + switch authorizationStatus { + case .notDetermined: + return "권한 미결정" + case .restricted: + return "권한 제한됨" + case .denied: + return "권한 거부됨" + case .authorizedAlways: + return "항상 허용" + case .authorizedWhenInUse: + return "사용 중 허용" + @unknown default: + return "알 수 없음" + } + } +} + +// MARK: - CLLocationManagerDelegate +extension LocationPermissionManager: CLLocationManagerDelegate { + + nonisolated public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + + Task { @MainActor in + self.currentLocation = location + self.locationError = nil + + // 지속적인 위치 업데이트 콜백 호출 + await self.onLocationUpdate?(location) + + // continuation이 있으면 결과 반환 (일회성 요청용) + if let continuation = self.locationContinuation { + self.locationContinuation = nil + continuation.resume(returning: location) + } + } + } + + nonisolated public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Task { @MainActor in + self.locationError = "위치 업데이트 실패: \(error.localizedDescription)" + + // 지속적인 위치 업데이트 에러 콜백 호출 + await self.onLocationError?(error) + + // continuation이 있으면 에러 반환 (일회성 요청용) + if let continuation = self.locationContinuation { + self.locationContinuation = nil + continuation.resume(throwing: error) + } + } + } + + nonisolated public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + Task { @MainActor in + self.authorizationStatus = status + self.locationError = nil + + // continuation이 있으면 권한 상태 반환 + if let continuation = self.authorizationContinuation { + self.authorizationContinuation = nil + continuation.resume(returning: status) + } + } + } +} From 792f348dd723c4e40613be72426ac3cb334421da Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:30:35 +0900 Subject: [PATCH 11/27] =?UTF-8?q?feat:=20=EC=97=AD=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=90=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TrainStationFeature 통합 역 선택 시스템과 LocationPermissionManager - 기반 위치 권한 처리, 향상된 탐색 기능을 구현합니다. - 인증/위치 권한 알림, 출발 시간 토스트, 탐색 후 상태 리셋 등 --- .../Coordinator/Reducer/HomeCoordinator.swift | 13 +- .../Sources/Main/Reducer/HomeFeature.swift | 249 +++++++++++++++++- .../Home/Sources/Main/View/HomeView.swift | 55 +++- 3 files changed, 297 insertions(+), 20 deletions(-) diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 40ba28a..1e84c41 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -48,6 +48,7 @@ public struct HomeCoordinator { public enum InnerAction: Equatable { case presentProfile case presentProfileWithAnimation + case presentExplore } // MARK: - NavigationAction @@ -91,6 +92,12 @@ extension HomeCoordinator { await send(.inner(.presentProfileWithAnimation)) } + case .routeAction(id: _, action: .home(.delegate(.presentExplore))): + return .send(.inner(.presentExplore)) + + case .routeAction(id: _, action: .home(.delegate(.presentAuth))): + return .send(.navigation(.presentAuth)) + case .routeAction(id: _, action: .profile(.navigation(.presentRoot))): return .send(.view(.backAction)) @@ -150,6 +157,10 @@ extension HomeCoordinator { case .presentProfileWithAnimation: state.routes.push(.profile(.init())) return .none + + case .presentExplore: + state.routes.push(.explore(.init())) + return .none } } @@ -167,5 +178,3 @@ extension HomeCoordinator { // MARK: - HomeScreen State Equatable & Hashable extension HomeCoordinator.HomeScreen.State: Equatable {} extension HomeCoordinator.HomeScreen.State: Hashable {} - - diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index 72b0fa3..b731806 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -7,13 +7,18 @@ import Foundation +import CoreLocation import ComposableArchitecture +import DesignSystem +import DomainInterface +import Entity import Utill @Reducer public struct HomeFeature { @Dependency(\.date.now) var now + @Dependency(\.keychainManager) var keychainManager public init() {} @@ -26,16 +31,30 @@ public struct HomeFeature { todayDate = currentDate } + @Presents var customAlert: CustomAlertState? + @Presents var trainStation: TrainStationFeature.State? var departureTimePickerVisible: Bool = false var departureTime: Date var currentTime: Date var todayDate: Date var isSelected: Bool = false var isDepartureTimeSet: Bool = false + var selectedStation: Station = .seoul + var hasSelectedStation: Bool = false + var customAlertMode: CustomAlertMode? = nil + var hasAppearedOnce: Bool = false + var shouldResetAfterExplore: Bool = false + } + + enum CustomAlertMode: Equatable, Hashable { + case loginRequired + case locationPermissionRequired } public enum Action: ViewAction, BindableAction { case binding(BindingAction) + case customAlert(PresentationAction) + case trainStation(PresentationAction) case view(View) case async(AsyncAction) case inner(InnerAction) @@ -46,24 +65,36 @@ public struct HomeFeature { //MARK: - ViewAction @CasePathable public enum View { + case onAppear + case profileButtonTapped + case selectStationButtonTapped case departureTimeButtonTapped case departureTimeChanged(Date) + case exploreNearbyButtonTapped } //MARK: - AsyncAction 비동기 처리 액션 public enum AsyncAction: Equatable { - + case checkProfileAccessToken + case requestHomeLocationPermission + case requestExploreLocationPermission } //MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { + case accessTokenCheckedForProfile(Bool) + case showDepartureWarningToast + case exploreLocationPermissionChecked(Bool) + case resetAfterExploreIfNeeded } //MARK: - NavigationAction public enum DelegateAction: Equatable { case presentProfile + case presentAuth + case presentExplore } @@ -76,6 +107,12 @@ public struct HomeFeature { case .binding: return .none + case .customAlert(let action): + return handleCustomAlertAction(state: &state, action: action) + + case .trainStation(let action): + return handleTrainStationAction(state: &state, action: action) + case .view(let viewAction): return handleViewAction(state: &state, action: viewAction) @@ -89,15 +126,95 @@ public struct HomeFeature { return handleDelegateAction(state: &state, action: delegateAction) } } + .ifLet(\.$customAlert, action: \.customAlert) { + CustomConfirmAlert() + } + .ifLet(\.$trainStation, action: \.trainStation) { + TrainStationFeature() + } } } extension HomeFeature { + private func handleCustomAlertAction( + state: inout State, + action: PresentationAction + ) -> Effect { + switch action { + case .presented(.confirmTapped): + switch state.customAlertMode { + case .loginRequired: + state.customAlert = nil + state.customAlertMode = nil + return .send(.delegate(.presentAuth)) + + case .locationPermissionRequired: + state.customAlert = nil + state.customAlertMode = nil + return .run { _ in + await MainActor.run { + LocationPermissionManager.shared.openLocationSettings() + } + } + + case .none: + state.customAlert = nil + return .none + } + + case .presented(.cancelTapped), .dismiss: + state.customAlert = nil + state.customAlertMode = nil + return .none + + case .presented(.policyTapped): + return .none + } + } + + private func handleTrainStationAction( + state: inout State, + action: PresentationAction + ) -> Effect { + switch action { + case .presented(.delegate(.stationSelected(let station))): + state.selectedStation = station + state.isSelected = false + state.hasSelectedStation = true + state.trainStation = nil + guard state.shouldShowDepartureWarningToast else { + return .none + } + return .send(.inner(.showDepartureWarningToast)) + + case .dismiss: + state.isSelected = false + return .none + + default: + return .none + } + } + private func handleViewAction( state: inout State, action: View ) -> Effect { switch action { + case .onAppear: + return .merge( + .send(.async(.requestHomeLocationPermission)), + .send(.inner(.resetAfterExploreIfNeeded)) + ) + + case .profileButtonTapped: + return .send(.async(.checkProfileAccessToken)) + + case .selectStationButtonTapped: + state.isSelected = true + state.trainStation = .init(selectedStation: state.selectedStation) + return .none + case .departureTimeButtonTapped: state.departureTimePickerVisible.toggle() return .none @@ -107,7 +224,16 @@ extension HomeFeature { state.departureTime = date state.departureTimePickerVisible = false state.isDepartureTimeSet = true - return .none + guard state.shouldShowDepartureWarningToast else { + return .none + } + return .send(.inner(.showDepartureWarningToast)) + + case .exploreNearbyButtonTapped: + guard state.isExploreNearbyEnabled else { + return .none + } + return .send(.async(.requestExploreLocationPermission)) } } @@ -115,7 +241,32 @@ extension HomeFeature { state: inout State, action: AsyncAction ) -> Effect { - return .none + switch action { + case .checkProfileAccessToken: + return .run { [keychainManager] send in + let accessToken = await keychainManager.accessToken() + let hasAccessToken = !(accessToken?.isEmpty ?? true) + await send(.inner(.accessTokenCheckedForProfile(hasAccessToken))) + } + + case .requestHomeLocationPermission: + return .run { _ in + let locationManager = await LocationPermissionManager.shared + let currentStatus = await locationManager.authorizationStatus + + guard currentStatus == .notDetermined else { return } + + _ = await locationManager.requestLocationPermission() + } + + case .requestExploreLocationPermission: + return .run { send in + let locationManager = await LocationPermissionManager.shared + let status = await locationManager.requestLocationPermission() + let isGranted = status == .authorizedWhenInUse || status == .authorizedAlways + await send(.inner(.exploreLocationPermissionChecked(isGranted))) + } + } } private func handleDelegateAction( @@ -125,6 +276,12 @@ extension HomeFeature { switch action { case .presentProfile: return .none + + case .presentAuth: + return .none + + case .presentExplore: + return .none } } @@ -132,15 +289,93 @@ extension HomeFeature { state: inout State, action: InnerAction ) -> Effect { - return .none + switch action { + case .accessTokenCheckedForProfile(let hasAccessToken): + guard hasAccessToken else { + state.customAlertMode = .loginRequired + state.customAlert = .alert( + title: "로그인 해주세요", + message: "로그인 후 프로필을 확인할 수 있어요.", + confirmTitle: "확인", + cancelTitle: "취소", + isDestructive: false + ) + return .none + } + return .send(.delegate(.presentProfile)) + + case .showDepartureWarningToast: + return .run { _ in + await MainActor.run { + ToastManager.shared.showWarning("대기 시간이 부족합니다 (최소 20분 필요)") + } + } + + case .exploreLocationPermissionChecked(let isGranted): + guard isGranted else { + state.customAlertMode = .locationPermissionRequired + state.customAlert = .alert( + title: "위치 권한이 필요합니다", + message: "주변 탐색을 사용하려면 위치 권한을 허용해주세요.", + confirmTitle: "설정", + cancelTitle: "취소", + isDestructive: false + ) + return .none + } + state.shouldResetAfterExplore = true + return .send(.delegate(.presentExplore)) + + case .resetAfterExploreIfNeeded: + if !state.hasAppearedOnce { + state.hasAppearedOnce = true + return .none + } + + guard state.shouldResetAfterExplore else { + return .none + } + + let currentDate = now + state.shouldResetAfterExplore = false + state.departureTimePickerVisible = false + state.departureTime = currentDate + state.currentTime = currentDate + state.todayDate = currentDate + state.isSelected = false + state.isDepartureTimeSet = false + state.selectedStation = .seoul + state.hasSelectedStation = false + return .none + } } } extension HomeFeature.State { + var remainingTotalMinutes: Int { + (remainingTime.hour ?? 0) * 60 + (remainingTime.minute ?? 0) + } + + var isStationReady: Bool { + hasSelectedStation || selectedStation == .seoul + } + + var isExploreNearbyEnabled: Bool { + isStationReady && isDepartureTimeSet && remainingTotalMinutes > 20 + } + var hasRemainingTimeResult: Bool { isDepartureTimeSet && !departureTimePickerVisible } + var shouldShowDepartureWarningToast: Bool { + guard isStationReady && isDepartureTimeSet else { + return false + } + + return remainingTotalMinutes <= 20 + } + var remainingTime: DateComponents { guard isDepartureTimeSet else { return DateComponents(hour: 0, minute: 0) @@ -161,11 +396,17 @@ extension HomeFeature.State { // MARK: - HomeReducer.State + Hashable extension HomeFeature.State: Hashable { public func hash(into hasher: inout Hasher) { + hasher.combine(trainStation) hasher.combine(departureTimePickerVisible) hasher.combine(todayDate) hasher.combine(isSelected) + hasher.combine(hasSelectedStation) hasher.combine(currentTime) hasher.combine(departureTime) hasher.combine(isDepartureTimeSet) + hasher.combine(selectedStation) + hasher.combine(customAlertMode) + hasher.combine(hasAppearedOnce) + hasher.combine(shouldResetAfterExplore) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index c0804a9..d88edbf 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -38,6 +38,26 @@ public struct HomeView: View { Spacer() } } + .presentDSModal( + item: $store.scope(state: \.trainStation, action: \.trainStation), + height: .fraction(0.88), + showDragIndicator: true + ) { trainStationStore in + TrainStationView(store: trainStationStore) + } + .customAlert($store.scope(state: \.customAlert, action: \.customAlert)) + .toastOverlay( + position: .bottom, + horizontalPadding: 20, + bottomPadding: 140 + ) + .onAppear { + store.send(.view(.onAppear)) + } + .onChange(of: store.shouldShowDepartureWarningToast) { _, shouldShow in + guard shouldShow else { return } + ToastManager.shared.showWarning("대기 시간이 부족합니다 (최소 20분 필요)") + } } } @@ -101,7 +121,7 @@ extension HomeView { Spacer() Button { - store.send(.delegate(.presentProfile)) + store.send(.view(.profileButtonTapped)) } label: { Image(asset: .profile) .resizable() @@ -118,18 +138,23 @@ extension HomeView { @ViewBuilder fileprivate func selectStationView() -> some View { - VStack(alignment: .center, spacing: 0) { - Text(store.todayDate.formattedKoreanDateWithWeekday()) - .pretendardCustomFont(textStyle: .body2Medium) - .foregroundStyle(.gray900.opacity(0.9)) - .padding(.bottom, 4) - - Text("SEOUL") - .pretendardFont(family: .Bold, size: 64) - .foregroundStyle(store.isSelected ? .gray900 : .slateGray) - .tracking(-2.2) + Button { + store.send(.view(.selectStationButtonTapped)) + } label: { + VStack(alignment: .center, spacing: 0) { + Text(store.todayDate.formattedKoreanDateWithWeekday()) + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray900.opacity(0.9)) + .padding(.bottom, 4) + + Text(store.selectedStation.homeTitle) + .pretendardFont(family: .Bold, size: 64) + .foregroundStyle(store.isSelected || store.hasSelectedStation ? .gray900 : .slateGray) + .tracking(-2.2) + } + .frame(maxWidth: .infinity) } - .frame(maxWidth: .infinity) + .buttonStyle(.plain) } @ViewBuilder @@ -258,10 +283,12 @@ extension HomeView { @ViewBuilder fileprivate func exploreNearbyButton() -> some View { CustomButton( - action: {}, + action: { + store.send(.view(.exploreNearbyButtonTapped)) + }, title: "주변 탐색 시작하기", config: CustomButtonConfig.create(), - isEnable: false + isEnable: store.isExploreNearbyEnabled ) .padding(.horizontal, 24) } From d905cf6d57f9466b243a58926917e4f6f0755639 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:34:47 +0900 Subject: [PATCH 12/27] =?UTF-8?q?feat:=20Profile=20=ED=9E=88=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EB=B0=8F=20UI=20=EA=B0=9C=EC=84=A0=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TravelHistoryCardItem을 Entity로 이동 - ProfileFeature에 히스토리 페이지네이션 추가 - 실제 히스토리 데이터 연동 및 빈 상태 처리 - ExploreView UI 단순화 - SettingReducer editUser 파라미터 수정 --- .../Sources/Explore/View/ExploreView.swift | 283 +++++------------- .../Components/TravelHistoryCardView.swift | 23 +- .../Sources/Main/Reducer/ProfileFeature.swift | 82 ++++- .../Sources/Main/View/ProfileView.swift | 93 +++--- .../Setting/Reducer/SettingReducer.swift | 1 - 5 files changed, 201 insertions(+), 281 deletions(-) diff --git a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift index d0f9043..763be30 100644 --- a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift +++ b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift @@ -9,232 +9,85 @@ import SwiftUI import ComposableArchitecture import CoreLocation + +import DesignSystem import Entity public struct ExploreView: View { @Bindable var store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - ZStack { - // 네이버 지도 뷰 - NaverMapComponent( - locationPermissionStatus: store.locationPermissionStatus, - currentLocation: store.currentLocation, - routeInfo: store.routeInfo, - destination: store.selectedDestination, - returnToLocation: store.shouldReturnToCurrentLocation - ) - .ignoresSafeArea(.all) - .frame(maxWidth: .infinity, maxHeight: .infinity) - - // 위치 권한 거부 시 오버레이 - if store.isLocationPermissionDenied { - LocationPermissionOverlay( - onSettingsButtonTapped: { - store.send(.view(.openSettings)) - }, - onRetryButtonTapped: { - store.send(.view(.retryLocationPermission)) - } - ) - } - - // 🎯 네이버 스타일 위치 버튼 (우측 하단) - VStack { - Spacer() - HStack { - Spacer() - - // 네이버 스타일 위치 버튼 - Button(action: { - store.send(.view(.returnToCurrentLocation)) - }) { - Image(systemName: "location.fill") - .font(.system(size: 18, weight: .medium)) - .foregroundColor(.blue) - .frame(width: 44, height: 44) - .background(Color.white) - .clipShape(Circle()) - .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) - } - .padding(.trailing, 16) - .padding(.bottom, 120) - } + @Environment(\.dismiss) private var dismiss + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ZStack { + NaverMapComponent( + locationPermissionStatus: store.locationPermissionStatus, + currentLocation: store.currentLocation, + routeInfo: store.routeInfo, + destination: store.selectedDestination, + returnToLocation: store.shouldReturnToCurrentLocation + ) + .ignoresSafeArea(.all) + + VStack(spacing: 0) { + HStack(spacing: 12) { + Button { + dismiss() + } label: { + Image(asset: .leftArrow) + .resizable() + .scaledToFit() + .frame(width: 56, height: 56) + .background(.staticWhite) + .clipShape(Circle()) } + .buttonStyle(.plain) - // 길찾기 컨트롤 UI - VStack { - // 경로 정보 표시 (상단으로 이동) - if let routeInfo = store.routeInfo, - let destination = store.selectedDestination { - routeInfoCard(routeInfo: routeInfo, destination: destination) - .padding(.horizontal) - .padding(.top, 50) // 상단 패딩으로 변경 - } + HStack { + Text("강릉역") + .pretendardCustomFont(textStyle: .titleRegular) + .foregroundStyle(.staticBlack) - Spacer() - - // 길찾기 버튼들 - HStack(spacing: 12) { - if store.currentLocation != nil && !store.isLocationPermissionDenied { - // 강남역으로 도보 가기 버튼 - Button(action: { - store.send(.view(.searchRouteToGangnam)) - }) { - HStack(spacing: 6) { - Image(systemName: "figure.walk") - Text("강남역으로") - } - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color.blue) - .cornerRadius(20) - } - .disabled(store.isLoadingRoute) - - // 경로 초기화 버튼 (경로가 있을 때만) - if store.routeInfo != nil { - Button(action: { - store.send(.view(.clearRoute)) - }) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 14)) - .foregroundColor(.red) - .padding(10) - .background(Color.white) - .cornerRadius(18) - .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) - } - } - } - } - .padding(.horizontal) - .padding(.bottom, 50) - - // 로딩 상태 - if store.isLoadingRoute { - HStack { - ProgressView() - .scaleEffect(0.8) - Text("경로를 찾는 중...") - .font(.caption) - .foregroundColor(.gray) - } - .padding() - .background(Color.white.opacity(0.9)) - .cornerRadius(10) - .padding(.bottom, 30) - } - - // 에러 메시지 - if let error = store.routeError { - Text("❌ \(error)") - .font(.caption) - .foregroundColor(.red) - .padding() - .background(Color.white.opacity(0.9)) - .cornerRadius(10) - .padding(.horizontal) - .padding(.bottom, 30) - } + Spacer() } - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - if store.locationPermissionStatus == .authorizedWhenInUse || - store.locationPermissionStatus == .authorizedAlways { - Button("정확한 위치") { - store.send(.view(.requestFullAccuracy)) - } - .font(.caption) - .foregroundColor(.blue) - } + .padding(.horizontal, 24) + .frame(height: 56) + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 28)) + } + .padding(.top, 8) + .padding(.horizontal, 20) + + Spacer() + + HStack { + Spacer() + + Button { + store.send(.view(.returnToCurrentLocation)) + } label: { + Image(systemName: "location") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(.staticBlack) + .frame(width: 56, height: 56) + .background(.staticWhite) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.12), radius: 8, y: 2) } - } - .onAppear { - store.send(.view(.onAppear)) - } - .onDisappear { - store.send(.view(.onDisappear)) - } - .alert($store.scope(state: \.alert, action: \.scope.alert)) - } - - // MARK: - 경로 정보 카드 - private func routeInfoCard(routeInfo: RouteInfo, destination: Destination) -> some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "location.circle.fill") - .foregroundColor(.red) - Text(destination.name) - .font(.system(size: 18, weight: .bold)) - Spacer() - } - - Divider() - - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: "figure.walk") - .foregroundColor(.blue) - Text("거리: \(formatDistance(routeInfo.distance))") - .font(.system(size: 14)) - } - - HStack { - Image(systemName: "clock.fill") - .foregroundColor(.green) - Text("도보: \(routeInfo.duration)분") - .font(.system(size: 14)) - } - } - - Spacer() - - if routeInfo.tollFare > 0 { - VStack(alignment: .trailing, spacing: 4) { - Text("톨비: \(formatCurrency(routeInfo.tollFare))") - .font(.system(size: 12)) - .foregroundColor(.orange) - - if routeInfo.taxiFare > 0 { - Text("택시비: \(formatCurrency(routeInfo.taxiFare))") - .font(.system(size: 12)) - .foregroundColor(.gray) - } - } - } - } + .padding(.trailing, 16) + .padding(.bottom, 36) } - .padding() - .background(Color.white.opacity(0.95)) - .cornerRadius(12) - .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2) + } } - - // MARK: - Helper Methods - private func formatDistance(_ meters: Int) -> String { - if meters < 1000 { - return "\(meters)m" - } else { - let kilometers = Double(meters) / 1000.0 - return String(format: "%.1fkm", kilometers) - } + .onAppear { + store.send(.view(.onAppear)) } - - private func formatCurrency(_ amount: Int) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return "\(formatter.string(from: NSNumber(value: amount)) ?? "\(amount)")원" + .onDisappear { + store.send(.view(.onDisappear)) } - + .alert($store.scope(state: \.alert, action: \.scope.alert)) + } } - - diff --git a/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift b/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift index ad44357..605ae3c 100644 --- a/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift +++ b/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift @@ -9,29 +9,8 @@ import SwiftUI import DesignSystem import Utill +import Entity -public struct TravelHistoryCardItem: Identifiable, Hashable { - public let id = UUID() - public let visitedAt: Date - public let placeName: String - public let departureName: String - public let durationText: String - public let departureTimeText: String - - public init( - visitedAt: Date, - placeName: String, - departureName: String, - durationText: String, - departureTimeText: String - ) { - self.visitedAt = visitedAt - self.placeName = placeName - self.departureName = departureName - self.durationText = durationText - self.departureTimeText = departureTimeText - } -} public struct TravelHistoryCardView: View { let item: TravelHistoryCardItem diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift index ef4e905..e60c6e8 100644 --- a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -20,8 +20,11 @@ public struct ProfileFeature { public struct State: Equatable { var travelHistorySort: TravelHistorySort = .recent var profileEntity: ProfileEntity? = nil + var historyEntity: HistoryEntity? = nil var errorMessage: String? = nil var isLoading: Bool = false + var isHistoryLoading: Bool = false + var isHistoryLoadingMore: Bool = false @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty public init() {} @@ -41,6 +44,7 @@ public struct ProfileFeature { public enum View { case onAppear case travelHistorySortSelected(TravelHistorySort) + case historyRowAppeared(Int) } @@ -48,11 +52,13 @@ public struct ProfileFeature { //MARK: - AsyncAction 비동기 처리 액션 public enum AsyncAction: Equatable { case fetchUser + case fetchMyHistory(page: Int, reset: Bool) } //MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { case fetchUserResponse(Result) + case fetchMyHistoryResponse(Result, reset: Bool) } //MARK: - NavigationAction @@ -65,9 +71,11 @@ public struct ProfileFeature { nonisolated enum CancelID: Hashable { case fetchUser + case fetchMyHistory } @Dependency(\.profileUseCase) var profileUseCase + @Dependency(\.historyUseCase) var historyUseCase @Dependency(\.keychainManager) var keychainManager public var body: some Reducer { @@ -101,12 +109,25 @@ extension ProfileFeature { switch action { case .travelHistorySortSelected(let sort): state.travelHistorySort = sort - return .none + return .send(.async(.fetchMyHistory(page: 1, reset: true))) + + case .historyRowAppeared(let id): + guard + let historyEntity = state.historyEntity, + historyEntity.items.last?.id == id, + let nextPage = historyEntity.nextPage, + !state.isHistoryLoading, + !state.isHistoryLoadingMore + else { + return .none + } + return .send(.async(.fetchMyHistory(page: nextPage, reset: false))) case .onAppear: - return .run { send in - await send(.async(.fetchUser)) - } + return .merge( + .send(.async(.fetchUser)), + .send(.async(.fetchMyHistory(page: 1, reset: true))) + ) } } @@ -129,6 +150,22 @@ extension ProfileFeature { } .cancellable(id: CancelID.fetchUser, cancelInFlight: true) + + case .fetchMyHistory(let page, let reset): + if reset { + state.isHistoryLoading = true + state.historyEntity = nil + } else { + state.isHistoryLoadingMore = true + } + return .run { [travelHistorySort = state.travelHistorySort] send in + let result = await Result { + try await historyUseCase.myHistory(page: page, size: 10, sort: travelHistorySort) + } + .mapError(ProfileError.from) + await send(.inner(.fetchMyHistoryResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetchMyHistory, cancelInFlight: reset) } } @@ -177,6 +214,40 @@ extension ProfileFeature { state.errorMessage = error.errorDescription return .none } + + case .fetchMyHistoryResponse(let result, let reset): + state.isHistoryLoading = false + state.isHistoryLoadingMore = false + switch result { + case .success(let data): + state.errorMessage = nil + if reset || state.historyEntity == nil { + state.historyEntity = data + } else { + state.historyEntity = HistoryEntity( + items: (state.historyEntity?.items ?? []) + data.items, + totalElements: data.totalElements, + totalPages: data.totalPages, + size: data.size, + page: data.page, + isFirstPage: data.isFirstPage, + isLastPage: data.isLastPage + ) + } + return .none + + case .failure(let error): + if error.shouldPresentAuth { + state.historyEntity = nil + state.errorMessage = nil + return .run { send in + try? await keychainManager.clear() + await send(.delegate(.presentAuth)) + } + } + state.errorMessage = error.errorDescription + return .none + } } } } @@ -186,8 +257,11 @@ extension ProfileFeature.State: Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(travelHistorySort) hasher.combine(profileEntity) + hasher.combine(historyEntity) hasher.combine(errorMessage) hasher.combine(isLoading) + hasher.combine(isHistoryLoading) + hasher.combine(isHistoryLoadingMore) hasher.combine(userSession) } } diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift index e69ec50..ac955d1 100644 --- a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift +++ b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift @@ -15,31 +15,6 @@ import ComposableArchitecture public struct ProfileView: View { @Bindable var store: StoreOf - //TODO: - 제거 할거 - private let travelHistoryItems: [TravelHistoryCardItem] = [ - .init( - visitedAt: Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 10, hour: 13, minute: 5)) ?? .now, - placeName: "스테이션 카페", - departureName: "강릉역", - durationText: "25분 소요", - departureTimeText: "오후 1:05" - ), - .init( - visitedAt: Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 7, hour: 9, minute: 20)) ?? .now, - placeName: "오죽헌", - departureName: "강릉역", - durationText: "18분 소요", - departureTimeText: "오전 9:20" - ), - .init( - visitedAt: Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 1, hour: 18, minute: 40)) ?? .now, - placeName: "안목해변", - departureName: "강릉역", - durationText: "32분 소요", - departureTimeText: "오후 6:40" - ) - ] - public init(store: StoreOf) { self.store = store } @@ -85,13 +60,8 @@ public struct ProfileView: View { } extension ProfileView { - private var sortedTravelHistoryItems: [TravelHistoryCardItem] { - switch store.travelHistorySort { - case .recent: - return travelHistoryItems.sorted { $0.visitedAt > $1.visitedAt } - case .oldest: - return travelHistoryItems.sorted { $0.visitedAt < $1.visitedAt } - } + private var travelHistoryItems: [HistoryItemEntity] { + store.historyEntity?.items ?? [] } @ViewBuilder @@ -165,7 +135,7 @@ extension ProfileView { Spacer() .frame(height: 4) - Text("24곳") + Text("\(store.profileEntity?.totalVisitCount ?? 0)곳") .pretendardFont(family: .SemiBold, size: 16) .foregroundStyle(.staticWhite) @@ -189,7 +159,7 @@ extension ProfileView { Spacer() .frame(height: 4) - Text("5시간 20분") + Text(store.profileEntity?.formattedJourneyTime ?? "0시간00분") .pretendardFont(family: .SemiBold, size: 16) .foregroundStyle(.staticWhite) @@ -269,14 +239,44 @@ extension ProfileView { Spacer() .frame(height: 20) - ScrollView(.vertical) { - VStack(spacing: 12) { - ForEach(sortedTravelHistoryItems) { item in - TravelHistoryCardView(item: item) + if travelHistoryItems.isEmpty, !store.isHistoryLoading, !store.isHistoryLoadingMore { + VStack(spacing: 0) { + Spacer() + + Image(asset: .empyTravel) + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + + Spacer() + .frame(height: 24) + + Text("저장된 히스토리가 없습니다.") + .pretendardCustomFont(textStyle: .bodyRegular) + .foregroundStyle(.gray550) + + Spacer() + } + .frame(maxWidth: .infinity) + } else { + ScrollView(.vertical) { + LazyVStack(spacing: 12) { + ForEach(travelHistoryItems) { item in + TravelHistoryCardView(item: item.toCardItem()) + .onAppear { + store.send(.view(.historyRowAppeared(item.id))) + } + } + + if store.isHistoryLoadingMore { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } } } + .scrollIndicators(.hidden) } - .scrollIndicators(.hidden) } } @@ -548,3 +548,18 @@ extension ProfileView { ) } } + +private extension HistoryItemEntity { + func toCardItem() -> TravelHistoryCardItem { + let visitedDate = startTime.toDate() ?? .now + let departureDate = trainDepartureTime.toDate() ?? .now + + return TravelHistoryCardItem( + visitedAt: visitedDate, + placeName: placeName, + departureName: stationName, + durationText: "\(totalDurationMinutes)분 소요", + departureTimeText: departureDate.formattedKoreanTime() + ) + } +} diff --git a/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift b/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift index 6164529..e2fbadd 100644 --- a/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift +++ b/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift @@ -198,7 +198,6 @@ extension SettingFeature { ] send in let result = await Result { try await profileUseCase.editUser( - name: userSession.name, mapType: userSession.mapType ) } From 12c6efd2d65b2dfcde5af22edb3779139e851ff7 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 22:40:27 +0900 Subject: [PATCH 13/27] =?UTF-8?q?feat:=20=EC=97=AC=ED=96=89=20=EC=97=AD=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=B6=94=EC=A0=81=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/Di/DiRegister.swift | 6 +- Projects/App/Sources/Reducer/AppReducer.swift | 3 + .../Entity/Sources/OAuth/UserSession.swift | 8 +- .../Sources/Main/Reducer/HomeFeature.swift | 14 ++++ .../Home/Sources/Main/View/HomeView.swift | 1 + .../Manager/LocationPermissionManager.swift | 6 +- .../Sources/Image/ImageAsset.swift | 4 + .../Sources/Ui/Toast/ToastView.swift | 77 +++++++++++++++++-- 8 files changed, 106 insertions(+), 13 deletions(-) diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index dd2904f..ebd7513 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -40,10 +40,12 @@ public final class AppDIManager { .register { AppleLoginRepositoryImpl() as AppleAuthRequestInterface } .register { AppleOAuthRepositoryImpl() as AppleOAuthInterface } .register { AppleOAuthProvider() as AppleOAuthProviderInterface } - // MARK: - 회원가입 + // MARK: - 회원가입 .register { SignUpRepositoryImpl() as SignUpInterface } - // MARK: - 프로필 + // MARK: - 프로필 .register(ProfileInterface.self) { ProfileRepositoryImpl() } + // MARK: - 히스토리 + .register(HistoryInterface.self) { HistoryRepositoryImpl() } diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index f20d6dd..52ce9ba 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -202,6 +202,9 @@ extension AppReducer { case .auth(.navigation(.presentMain)): return .send(.view(.presentRoot)) + case .home(.router(.routeAction(id: _, action: .home(.delegate(.presentAuth))))): + return .send(.view(.presentAuth)) + case .home(.router(.routeAction(id: _, action: .profile(.navigation(.presentAuth))))): return .send(.view(.presentAuth)) diff --git a/Projects/Domain/Entity/Sources/OAuth/UserSession.swift b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift index 28ace17..7bf4351 100644 --- a/Projects/Domain/Entity/Sources/OAuth/UserSession.swift +++ b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift @@ -13,19 +13,25 @@ public struct UserSession: Equatable, Hashable { public var provider: SocialType public var authCode: String public var mapType: ExternalMapType + public var travelID: String + public var travelStationName: String public init( name: String = "", email: String = "", provider: SocialType = .apple, authCode: String = "", - mapType: ExternalMapType = .appleMap + mapType: ExternalMapType = .appleMap, + travelID: String = "", + travelStationName: String = "" ) { self.name = name self.email = email self.provider = provider self.authCode = authCode self.mapType = mapType + self.travelID = travelID + self.travelStationName = travelStationName } } diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index b731806..daa21df 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -44,6 +44,7 @@ public struct HomeFeature { var customAlertMode: CustomAlertMode? = nil var hasAppearedOnce: Bool = false var shouldResetAfterExplore: Bool = false + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty } enum CustomAlertMode: Equatable, Hashable { @@ -182,6 +183,10 @@ extension HomeFeature { state.isSelected = false state.hasSelectedStation = true state.trainStation = nil + state.$userSession.withLock { + $0.travelID = station.id + $0.travelStationName = station.displayName + } guard state.shouldShowDepartureWarningToast else { return .none } @@ -216,6 +221,10 @@ extension HomeFeature { return .none case .departureTimeButtonTapped: + state.currentTime = now + if state.departureTime < state.currentTime { + state.departureTime = state.currentTime + } state.departureTimePickerVisible.toggle() return .none @@ -346,6 +355,10 @@ extension HomeFeature { state.isDepartureTimeSet = false state.selectedStation = .seoul state.hasSelectedStation = false + state.$userSession.withLock { + $0.travelID = "" + $0.travelStationName = "" + } return .none } } @@ -408,5 +421,6 @@ extension HomeFeature.State: Hashable { hasher.combine(customAlertMode) hasher.combine(hasAppearedOnce) hasher.combine(shouldResetAfterExplore) + hasher.combine(userSession) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index d88edbf..0fdcaa7 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -191,6 +191,7 @@ extension HomeView { DatePicker( "출발 시간 선택", selection: $store.departureTime, + in: store.currentTime..., displayedComponents: [.hourAndMinute] ) .datePickerStyle(.wheel) diff --git a/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift b/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift index 7168b09..8ec7a66 100644 --- a/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift +++ b/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift @@ -150,8 +150,10 @@ public final class LocationPermissionManager: NSObject, ObservableObject { } // 위치 서비스 사용 가능 여부 - public var isLocationServicesEnabled: Bool { - CLLocationManager.locationServicesEnabled() + public func isLocationServicesEnabled() async -> Bool { + await Task.detached { + CLLocationManager.locationServicesEnabled() + }.value } // 권한 상태 문자열 diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 73c811a..61ad505 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -33,6 +33,7 @@ public enum ImageAsset: String { case logo case loginlogo + case warning case setting case time @@ -40,6 +41,9 @@ public enum ImageAsset: String { case arrowtriangleDown case travelLine case warningTriangle + case mapSharp + case subway + case empyTravel case none } diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift index 295e5ec..77f52c1 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift @@ -41,19 +41,36 @@ public struct ToastView: View { // MARK: - Toast Overlay Modifier public struct ToastOverlay: ViewModifier { @ObservedObject private var toastManager = ToastManager.shared + let position: ToastPosition + let horizontalPadding: CGFloat + let topPadding: CGFloat + let bottomPadding: CGFloat + + public init( + position: ToastPosition = .top, + horizontalPadding: CGFloat = 20, + topPadding: CGFloat = 30, + bottomPadding: CGFloat = 30 + ) { + self.position = position + self.horizontalPadding = horizontalPadding + self.topPadding = topPadding + self.bottomPadding = bottomPadding + } public func body(content: Content) -> some View { content - .overlay(alignment: .top) { + .overlay(alignment: position.alignment) { if let toast = toastManager.currentToast { ToastView(toast: toast) - .padding(.horizontal, 20) - .padding(.top, 30) + .padding(.horizontal, horizontalPadding) + .padding(.top, position == .top ? topPadding : 0) + .padding(.bottom, position == .bottom ? bottomPadding : 0) .opacity(toastManager.isVisible ? 1 : 0) - .offset(y: toastManager.isVisible ? 0 : -100) + .offset(y: toastManager.isVisible ? 0 : position.hiddenOffsetY) .transition(.asymmetric( - insertion: .move(edge: .top).combined(with: .opacity), - removal: .move(edge: .top).combined(with: .opacity) + insertion: .move(edge: position.edge).combined(with: .opacity), + removal: .move(edge: position.edge).combined(with: .opacity) )) .allowsHitTesting(toastManager.isVisible) } @@ -61,10 +78,54 @@ public struct ToastOverlay: ViewModifier { } } +public enum ToastPosition: Equatable { + case top + case bottom + + var alignment: Alignment { + switch self { + case .top: + return .top + case .bottom: + return .bottom + } + } + + var edge: Edge { + switch self { + case .top: + return .top + case .bottom: + return .bottom + } + } + + var hiddenOffsetY: CGFloat { + switch self { + case .top: + return -100 + case .bottom: + return 100 + } + } +} + // MARK: - View Extension public extension View { - func toastOverlay() -> some View { - modifier(ToastOverlay()) + func toastOverlay( + position: ToastPosition = .top, + horizontalPadding: CGFloat = 20, + topPadding: CGFloat = 30, + bottomPadding: CGFloat = 30 + ) -> some View { + modifier( + ToastOverlay( + position: position, + horizontalPadding: horizontalPadding, + topPadding: topPadding, + bottomPadding: bottomPadding + ) + ) } } From 1fab950291d4ebe215c57ef4a3ddd1b56c3c7b18 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 23:39:57 +0900 Subject: [PATCH 14/27] =?UTF-8?q?feat:=20Station=20API=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/API/Domain/TimeSpotDomain.swift | 6 ++++ .../API/Sources/API/Station/StationAPI.swift | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 Projects/Data/API/Sources/API/Station/StationAPI.swift diff --git a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift index 6f7db44..c723de9 100644 --- a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift +++ b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift @@ -14,6 +14,8 @@ public enum TimeSpotDomain { case place case profile case history + case station + case favorite } extension TimeSpotDomain: DomainType { @@ -31,6 +33,10 @@ extension TimeSpotDomain: DomainType { return "api/v1/users" case .history: return "api/v1/histories" + case .station: + return "api/v1/stations" + case .favorite: + return "api/v1/favorites" } } } diff --git a/Projects/Data/API/Sources/API/Station/StationAPI.swift b/Projects/Data/API/Sources/API/Station/StationAPI.swift new file mode 100644 index 0000000..6139ff5 --- /dev/null +++ b/Projects/Data/API/Sources/API/Station/StationAPI.swift @@ -0,0 +1,28 @@ +// +// StationAPI.swift +// API +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public enum StationAPI { + case allStation + case favoriteStation + case addFavoriteStation + case deleteFavoriteStation(deleteStationId: Int) + + public var description: String { + switch self { + case .allStation: + return "" + case .favoriteStation: + return "" + case .addFavoriteStation: + return "" + case .deleteFavoriteStation(let deleteStationId): + return "/\(deleteStationId)" + } + } +} From dd8118c5635488db05482db5758be79964a4f4a4 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 23:41:18 +0900 Subject: [PATCH 15/27] =?UTF-8?q?feat:=20Station=20DTO=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EB=B0=8F=20=EB=A7=A4=ED=8D=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Station/DTO/FavoriteStationDTOModel.swift | 68 +++++++++++++++ .../DTO/FavoriteStationMutationDTOModel.swift | 37 +++++++++ .../Sources/Station/DTO/StationDTOModel.swift | 82 +++++++++++++++++++ .../Mapper/FavoriteStationDTOModel+.swift | 34 ++++++++ .../FavoriteStationMutationDTOModel+.swift | 17 ++++ .../Station/Mapper/StationDTOModel+.swift | 44 ++++++++++ 6 files changed, 282 insertions(+) create mode 100644 Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift create mode 100644 Projects/Data/Model/Sources/Station/DTO/FavoriteStationMutationDTOModel.swift create mode 100644 Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift create mode 100644 Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift create mode 100644 Projects/Data/Model/Sources/Station/Mapper/FavoriteStationMutationDTOModel+.swift create mode 100644 Projects/Data/Model/Sources/Station/Mapper/StationDTOModel+.swift diff --git a/Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift b/Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift new file mode 100644 index 0000000..dca646c --- /dev/null +++ b/Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift @@ -0,0 +1,68 @@ +// +// FavoriteStationDTOModel.swift +// Model +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public typealias FavoriteStationDTOModel = BaseResponseDTO + +public struct FavoriteStationPageResponseDTO: Decodable, Equatable { + public let content: [FavoriteStationItemResponseDTO] + public let totalElements: Int + public let totalPages: Int + public let size: Int + public let number: Int + public let first: Bool + public let last: Bool + + public init( + content: [FavoriteStationItemResponseDTO], + totalElements: Int, + totalPages: Int, + size: Int, + number: Int, + first: Bool, + last: Bool + ) { + self.content = content + self.totalElements = totalElements + self.totalPages = totalPages + self.size = size + self.number = number + self.first = first + self.last = last + } +} + +public struct FavoriteStationItemResponseDTO: Decodable, Equatable { + public let favoriteID: Int + public let stationID: Int + public let stationName: String + public let visitCount: Int + public let createdAt: String + + enum CodingKeys: String, CodingKey { + case favoriteID = "favoriteId" + case stationID = "stationId" + case stationName + case visitCount + case createdAt + } + + public init( + favoriteID: Int, + stationID: Int, + stationName: String, + visitCount: Int, + createdAt: String + ) { + self.favoriteID = favoriteID + self.stationID = stationID + self.stationName = stationName + self.visitCount = visitCount + self.createdAt = createdAt + } +} diff --git a/Projects/Data/Model/Sources/Station/DTO/FavoriteStationMutationDTOModel.swift b/Projects/Data/Model/Sources/Station/DTO/FavoriteStationMutationDTOModel.swift new file mode 100644 index 0000000..85f0cdf --- /dev/null +++ b/Projects/Data/Model/Sources/Station/DTO/FavoriteStationMutationDTOModel.swift @@ -0,0 +1,37 @@ +// +// FavoriteStationMutationDTOModel.swift +// Model +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public struct FavoriteStationMutationDTOModel: Decodable, Equatable { + public let code: Int + public let message: String + public let data: EmptyResponseDTO? + + public init( + code: Int, + message: String, + data: EmptyResponseDTO? = nil + ) { + self.code = code + self.message = message + self.data = data + } + + enum CodingKeys: String, CodingKey { + case code + case message + case data + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.code = try container.decode(Int.self, forKey: .code) + self.message = try container.decode(String.self, forKey: .message) + self.data = try container.decodeIfPresent(EmptyResponseDTO.self, forKey: .data) + } +} diff --git a/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift b/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift new file mode 100644 index 0000000..8c5bd70 --- /dev/null +++ b/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift @@ -0,0 +1,82 @@ +// +// StationDTOModel.swift +// Model +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public typealias StationDTOModel = BaseResponseDTO + +public struct StationListResponseDTO: Decodable, Equatable { + public let favoriteStations: [StationSummaryResponseDTO] + public let nearbyStations: [StationSummaryResponseDTO] + public let stations: StationPageResponseDTO + + public init( + favoriteStations: [StationSummaryResponseDTO], + nearbyStations: [StationSummaryResponseDTO], + stations: StationPageResponseDTO + ) { + self.favoriteStations = favoriteStations + self.nearbyStations = nearbyStations + self.stations = stations + } +} + +public struct StationSummaryResponseDTO: Decodable, Equatable { + public let stationID: Int + public let name: String + public let lines: [String] + + enum CodingKeys: String, CodingKey { + case stationID = "stationId" + case name + case lines + } + + public init( + stationID: Int, + name: String, + lines: [String] + ) { + self.stationID = stationID + self.name = name + self.lines = lines + } +} + +public struct StationPageResponseDTO: Decodable, Equatable { + public let content: [StationSummaryResponseDTO] + public let totalElements: Int + public let totalPages: Int + public let last: Bool + public let first: Bool + public let numberOfElements: Int + public let size: Int + public let number: Int + public let empty: Bool + + public init( + content: [StationSummaryResponseDTO], + totalElements: Int, + totalPages: Int, + last: Bool, + first: Bool, + numberOfElements: Int, + size: Int, + number: Int, + empty: Bool + ) { + self.content = content + self.totalElements = totalElements + self.totalPages = totalPages + self.last = last + self.first = first + self.numberOfElements = numberOfElements + self.size = size + self.number = number + self.empty = empty + } +} diff --git a/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift b/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift new file mode 100644 index 0000000..cb10600 --- /dev/null +++ b/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift @@ -0,0 +1,34 @@ +// +// FavoriteStationDTOModel+.swift +// Model +// +// Created by Wonji Suh on 3/26/26. +// + +import Entity + +public extension FavoriteStationPageResponseDTO { + func toDomain() -> FavoriteStationEntity { + FavoriteStationEntity( + items: content.map { $0.toDomain() }, + totalElements: totalElements, + totalPages: totalPages, + size: size, + page: number + 1, + isFirstPage: first, + isLastPage: last + ) + } +} + +public extension FavoriteStationItemResponseDTO { + func toDomain() -> FavoriteStationItemEntity { + FavoriteStationItemEntity( + favoriteID: favoriteID, + stationID: stationID, + stationName: stationName, + visitCount: visitCount, + createdAt: createdAt + ) + } +} diff --git a/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationMutationDTOModel+.swift b/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationMutationDTOModel+.swift new file mode 100644 index 0000000..83511be --- /dev/null +++ b/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationMutationDTOModel+.swift @@ -0,0 +1,17 @@ +// +// FavoriteStationMutationDTOModel+.swift +// Model +// +// Created by Wonji Suh on 3/26/26. +// + +import Entity + +public extension FavoriteStationMutationDTOModel { + func toDomain() -> FavoriteStationMutationEntity { + FavoriteStationMutationEntity( + code: code, + message: message + ) + } +} diff --git a/Projects/Data/Model/Sources/Station/Mapper/StationDTOModel+.swift b/Projects/Data/Model/Sources/Station/Mapper/StationDTOModel+.swift new file mode 100644 index 0000000..ac8adc6 --- /dev/null +++ b/Projects/Data/Model/Sources/Station/Mapper/StationDTOModel+.swift @@ -0,0 +1,44 @@ +// +// StationDTOModel+.swift +// Model +// +// Created by Wonji Suh on 3/26/26. +// + +import Entity + +public extension StationListResponseDTO { + func toDomain() -> StationListEntity { + StationListEntity( + favoriteStations: favoriteStations.map { $0.toDomain() }, + nearbyStations: nearbyStations.map { $0.toDomain() }, + stations: stations.toDomain() + ) + } +} + +public extension StationSummaryResponseDTO { + func toDomain() -> StationSummaryEntity { + StationSummaryEntity( + stationID: stationID, + name: name, + lines: lines + ) + } +} + +public extension StationPageResponseDTO { + func toDomain() -> StationPageEntity { + StationPageEntity( + content: content.map { $0.toDomain() }, + totalElements: totalElements, + totalPages: totalPages, + size: size, + page: number + 1, + numberOfElements: numberOfElements, + isFirstPage: first, + isLastPage: last, + isEmpty: empty + ) + } +} From a7bdff186634ad4ce8df9450371eecfdf56bea4d Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 23:42:30 +0900 Subject: [PATCH 16/27] =?UTF-8?q?feat:=20=EC=97=AD=20API=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=B0=8F=20=EC=9A=94=EC=B2=AD=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=B6=94=EA=B0=80=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Station/StationRequest.swift | 58 +++++++++++++ .../Sources/Station/StationService.swift | 84 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 Projects/Data/Service/Sources/Station/StationRequest.swift create mode 100644 Projects/Data/Service/Sources/Station/StationService.swift diff --git a/Projects/Data/Service/Sources/Station/StationRequest.swift b/Projects/Data/Service/Sources/Station/StationRequest.swift new file mode 100644 index 0000000..f68e548 --- /dev/null +++ b/Projects/Data/Service/Sources/Station/StationRequest.swift @@ -0,0 +1,58 @@ +// +// StationRequest.swift +// Service +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public struct StationRequest: Encodable, Equatable { + public let lat: Double + public let lng: Double + public let page: Int + public let size: Int + public let sort: String + + public init( + lat: Double, + lng: Double, + page: Int = 1, + size: Int = 10, + sort: String = "stationName,ASC" + ) { + self.lat = lat + self.lng = lng + self.page = max(page, 1) + self.size = max(size, 10) + self.sort = sort + } +} + +public struct FavoriteStationRequest: Encodable, Equatable { + public let page: Int + public let size: Int + public let sort: String + + public init( + page: Int = 1, + size: Int = 10, + sort: String = "stationName,ASC" + ) { + self.page = max(page, 1) + self.size = max(size, 10) + self.sort = sort + } +} + +public struct AddFavoriteStationRequest: Encodable, Equatable { + public let stationID: Int + + enum CodingKeys: String, CodingKey { + case stationID = "stationId" + } + + public init(stationID: Int) { + self.stationID = stationID + } +} diff --git a/Projects/Data/Service/Sources/Station/StationService.swift b/Projects/Data/Service/Sources/Station/StationService.swift new file mode 100644 index 0000000..56a1eb0 --- /dev/null +++ b/Projects/Data/Service/Sources/Station/StationService.swift @@ -0,0 +1,84 @@ +// +// StationService.swift +// Service +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum StationService { + case allStation(body: StationRequest) + case favoriteStation(body: FavoriteStationRequest) + case addFavoriteStation(body: AddFavoriteStationRequest) + case deleteFavoriteStation(deleteStationId: Int) +} + + +extension StationService: BaseTargetType { + public typealias Domain = TimeSpotDomain + + public var domain: TimeSpotDomain { + switch self { + case .allStation: + return .station + case .favoriteStation, .addFavoriteStation, .deleteFavoriteStation: + return .favorite + } + } + + public var urlPath: String { + switch self { + case .allStation: + return StationAPI.allStation.description + case .favoriteStation: + return StationAPI.favoriteStation.description + case .addFavoriteStation: + return StationAPI.addFavoriteStation.description + case .deleteFavoriteStation(let deleteStationId): + return StationAPI.deleteFavoriteStation(deleteStationId: deleteStationId).description + } + } + + public var error: [Int : NetworkError]? { + nil + } + + public var method: Moya.Method { + switch self { + case .allStation, .favoriteStation: + return .get + case .addFavoriteStation: + return .post + case .deleteFavoriteStation: + return .delete + } + } + + public var parameters: [String : Any]? { + switch self { + case .allStation(let body): + return body.toDictionary + case .favoriteStation(let body): + return body.toDictionary + case .addFavoriteStation(let body): + return body.toDictionary + case .deleteFavoriteStation: + return nil + } + } + + public var headers: [String : String]? { + switch self { + case .allStation: + return APIHeader.notAccessTokenHeader + case .favoriteStation, .addFavoriteStation, .deleteFavoriteStation: + return APIHeader.baseHeader + } + } +} From bfcb446764f9a60e4823f369e5ae22118af0f182 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 23:44:34 +0900 Subject: [PATCH 17/27] =?UTF-8?q?feat:=20StationRepository=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Station/StationRepositoryImpl.swift | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift diff --git a/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift b/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift new file mode 100644 index 0000000..ded0d6b --- /dev/null +++ b/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift @@ -0,0 +1,71 @@ +// +// StationRepositoryImpl.swift +// Repository +// +// Created by Wonji Suh on 3/26/26. +// + +import DomainInterface +import Model +import Entity + +import Service + +import AsyncMoya + +public final class StationRepositoryImpl: StationInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func fetchStations( + lat: Double, + lng: Double, + page: Int, + size: Int + ) async throws -> StationListEntity { + let body: StationRequest = .init( + lat: lat, + lng: lng, + page: page, + size: size, + sort: "stationName,ASC" + ) + let dto: StationDTOModel = try await provider.request(.allStation(body: body)) + return dto.data.toDomain() + } + + public func fetchFavoriteStations( + page: Int, + size: Int + ) async throws -> FavoriteStationEntity { + let body: FavoriteStationRequest = .init( + page: page, + size: size, + sort: "stationName,ASC" + ) + let dto: FavoriteStationDTOModel = try await provider.request(.favoriteStation(body: body)) + return dto.data.toDomain() + } + + public func addFavoriteStation( + stationID: Int + ) async throws -> FavoriteStationMutationEntity { + let body: AddFavoriteStationRequest = .init(stationID: stationID) + let dto: FavoriteStationMutationDTOModel = try await provider.request(.addFavoriteStation(body: body)) + return dto.toDomain() + } + + public func deleteFavoriteStation( + favoriteID: Int + ) async throws -> FavoriteStationMutationEntity { + let dto: FavoriteStationMutationDTOModel = try await provider.request( + .deleteFavoriteStation(deleteStationId: favoriteID) + ) + return dto.toDomain() + } +} From a043db391bcd196e9174d3f6e3bf0264543bf329 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 23:46:17 +0900 Subject: [PATCH 18/27] =?UTF-8?q?feat:=20Station=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20Station=20enum=20=ED=99=95=EC=9E=A5=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Station/FavoriteStationEntity.swift | 70 +++++++++++++++ .../FavoriteStationMutationEntity.swift | 21 +++++ .../Sources/Station/StationListEntity.swift | 86 +++++++++++++++++++ .../Entity/Sources/TrainStation/Station.swift | 35 ++++++++ 4 files changed, 212 insertions(+) create mode 100644 Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift create mode 100644 Projects/Domain/Entity/Sources/Station/FavoriteStationMutationEntity.swift create mode 100644 Projects/Domain/Entity/Sources/Station/StationListEntity.swift diff --git a/Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift b/Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift new file mode 100644 index 0000000..7510b5e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift @@ -0,0 +1,70 @@ +// +// FavoriteStationEntity.swift +// Entity +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public struct FavoriteStationEntity: Equatable, Hashable { + public let items: [FavoriteStationItemEntity] + public let totalElements: Int + public let totalPages: Int + public let size: Int + public let page: Int + public let isFirstPage: Bool + public let isLastPage: Bool + + public init( + items: [FavoriteStationItemEntity], + totalElements: Int, + totalPages: Int, + size: Int, + page: Int, + isFirstPage: Bool, + isLastPage: Bool + ) { + self.items = items + self.totalElements = totalElements + self.totalPages = totalPages + self.size = size + self.page = page + self.isFirstPage = isFirstPage + self.isLastPage = isLastPage + } +} + +public extension FavoriteStationEntity { + var hasNextPage: Bool { + !isLastPage && page < totalPages + } + + var nextPage: Int? { + hasNextPage ? page + 1 : nil + } +} + +public struct FavoriteStationItemEntity: Equatable, Hashable, Identifiable { + public let favoriteID: Int + public let stationID: Int + public let stationName: String + public let visitCount: Int + public let createdAt: String + + public var id: Int { favoriteID } + + public init( + favoriteID: Int, + stationID: Int, + stationName: String, + visitCount: Int, + createdAt: String + ) { + self.favoriteID = favoriteID + self.stationID = stationID + self.stationName = stationName + self.visitCount = visitCount + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/Station/FavoriteStationMutationEntity.swift b/Projects/Domain/Entity/Sources/Station/FavoriteStationMutationEntity.swift new file mode 100644 index 0000000..f74d826 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Station/FavoriteStationMutationEntity.swift @@ -0,0 +1,21 @@ +// +// FavoriteStationMutationEntity.swift +// Entity +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public struct FavoriteStationMutationEntity: Equatable, Hashable { + public let code: Int + public let message: String + + public init( + code: Int, + message: String + ) { + self.code = code + self.message = message + } +} diff --git a/Projects/Domain/Entity/Sources/Station/StationListEntity.swift b/Projects/Domain/Entity/Sources/Station/StationListEntity.swift new file mode 100644 index 0000000..d551a4f --- /dev/null +++ b/Projects/Domain/Entity/Sources/Station/StationListEntity.swift @@ -0,0 +1,86 @@ +// +// StationListEntity.swift +// Entity +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public struct StationListEntity: Equatable, Hashable { + public let favoriteStations: [StationSummaryEntity] + public let nearbyStations: [StationSummaryEntity] + public let stations: StationPageEntity + + public init( + favoriteStations: [StationSummaryEntity], + nearbyStations: [StationSummaryEntity], + stations: StationPageEntity + ) { + self.favoriteStations = favoriteStations + self.nearbyStations = nearbyStations + self.stations = stations + } +} + +public struct StationSummaryEntity: Equatable, Hashable, Identifiable { + public let stationID: Int + public let name: String + public let lines: [String] + + public var id: Int { stationID } + + public init( + stationID: Int, + name: String, + lines: [String] + ) { + self.stationID = stationID + self.name = name + self.lines = lines + } +} + +public struct StationPageEntity: Equatable, Hashable { + public let content: [StationSummaryEntity] + public let totalElements: Int + public let totalPages: Int + public let size: Int + public let page: Int + public let numberOfElements: Int + public let isFirstPage: Bool + public let isLastPage: Bool + public let isEmpty: Bool + + public init( + content: [StationSummaryEntity], + totalElements: Int, + totalPages: Int, + size: Int, + page: Int, + numberOfElements: Int, + isFirstPage: Bool, + isLastPage: Bool, + isEmpty: Bool + ) { + self.content = content + self.totalElements = totalElements + self.totalPages = totalPages + self.size = size + self.page = page + self.numberOfElements = numberOfElements + self.isFirstPage = isFirstPage + self.isLastPage = isLastPage + self.isEmpty = isEmpty + } +} + +public extension StationPageEntity { + var hasNextPage: Bool { + !isLastPage && page < totalPages + } + + var nextPage: Int? { + hasNextPage ? page + 1 : nil + } +} diff --git a/Projects/Domain/Entity/Sources/TrainStation/Station.swift b/Projects/Domain/Entity/Sources/TrainStation/Station.swift index 25e635f..c7c5b26 100644 --- a/Projects/Domain/Entity/Sources/TrainStation/Station.swift +++ b/Projects/Domain/Entity/Sources/TrainStation/Station.swift @@ -13,6 +13,8 @@ public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { case busan case dongdaegu case daejeon + case gangneung + case cheongnyangri public var id: String { rawValue } @@ -28,6 +30,10 @@ public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { return "동대구" case .daejeon: return "대전" + case .gangneung: + return "강릉" + case .cheongnyangri: + return "청량리" } } @@ -43,6 +49,35 @@ public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { return "DONGDAEGU" case .daejeon: return "DAEJEON" + case .gangneung: + return "GANGNEUNG" + case .cheongnyangri: + return "CHEONGNYANGNI" + } + } + + public init?(displayName: String) { + let normalized = displayName + .replacingOccurrences(of: "역", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + switch normalized { + case "서울": + self = .seoul + case "용산": + self = .yongsan + case "부산": + self = .busan + case "동대구": + self = .dongdaegu + case "대전": + self = .daejeon + case "강릉": + self = .gangneung + case "청량리": + self = .cheongnyangri + default: + return nil } } } From aa4306ff643322c0781d5e2b2a2d0da19caed8d4 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 23:47:03 +0900 Subject: [PATCH 19/27] =?UTF-8?q?feat:=20Station=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20UseCase=20=EC=B6=94=EA=B0=80=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultStationRepositoryImpl.swift | 101 ++++++++++++++++++ .../Sources/Station/StationInterface.swift | 51 +++++++++ .../Sources/Auth/AuthUseCaseImpl.swift | 2 +- .../Sources/Station/StationUseCaseImpl.swift | 66 ++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift create mode 100644 Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift diff --git a/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift new file mode 100644 index 0000000..7d212c0 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift @@ -0,0 +1,101 @@ +// +// DefaultStationRepositoryImpl.swift +// DomainInterface +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation +import Entity + +final public class DefaultStationRepositoryImpl: StationInterface { + public init() {} + + public func fetchStations( + lat: Double, + lng: Double, + page: Int, + size: Int + ) async throws -> StationListEntity { + let favoriteStations: [StationSummaryEntity] = [ + .init(stationID: 1, name: "서울", lines: ["경부선"]), + .init(stationID: 4, name: "동대구", lines: ["경부선"]) + ] + + let nearbyStations: [StationSummaryEntity] = [ + .init(stationID: 1, name: "서울", lines: ["경부선", "호남선", "전라선", "강릉선"]) + ] + + let stations: [StationSummaryEntity] = [ + .init(stationID: 1, name: "서울", lines: ["경부선", "호남선", "전라선", "강릉선"]), + .init(stationID: 2, name: "용산", lines: ["호남선", "전라선"]), + .init(stationID: 23, name: "청량리", lines: ["강릉선", "중앙선"]) + ] + + return StationListEntity( + favoriteStations: favoriteStations, + nearbyStations: nearbyStations, + stations: StationPageEntity( + content: stations, + totalElements: stations.count, + totalPages: 1, + size: size, + page: page, + numberOfElements: stations.count, + isFirstPage: page == 1, + isLastPage: true, + isEmpty: stations.isEmpty + ) + ) + } + + public func fetchFavoriteStations( + page: Int, + size: Int + ) async throws -> FavoriteStationEntity { + let items: [FavoriteStationItemEntity] = [ + .init( + favoriteID: 1, + stationID: 10, + stationName: "서울역", + visitCount: 5, + createdAt: "2024-03-24T16:00:00" + ), + .init( + favoriteID: 2, + stationID: 20, + stationName: "강남역", + visitCount: 3, + createdAt: "2024-03-23T10:30:00" + ) + ] + + return FavoriteStationEntity( + items: items, + totalElements: items.count, + totalPages: 1, + size: size, + page: page, + isFirstPage: page == 1, + isLastPage: true + ) + } + + public func addFavoriteStation( + stationID: Int + ) async throws -> FavoriteStationMutationEntity { + FavoriteStationMutationEntity( + code: 201, + message: "즐겨찾기가 성공적으로 생성되었습니다." + ) + } + + public func deleteFavoriteStation( + favoriteID: Int + ) async throws -> FavoriteStationMutationEntity { + FavoriteStationMutationEntity( + code: 200, + message: "즐겨찾기가 성공적으로 삭제되었습니다." + ) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift b/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift new file mode 100644 index 0000000..0d73482 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift @@ -0,0 +1,51 @@ +// +// StationInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 3/26/26. +// + +import Entity +import ComposableArchitecture +import WeaveDI + +public protocol StationInterface: Sendable { + func fetchStations( + lat: Double, + lng: Double, + page: Int, + size: Int + ) async throws -> StationListEntity + + func fetchFavoriteStations( + page: Int, + size: Int + ) async throws -> FavoriteStationEntity + + func addFavoriteStation( + stationID: Int + ) async throws -> FavoriteStationMutationEntity + + func deleteFavoriteStation( + favoriteID: Int + ) async throws -> FavoriteStationMutationEntity +} + +public struct StationRepositoryDependency: DependencyKey { + public static var liveValue: StationInterface { + UnifiedDI.resolve(StationInterface.self) ?? DefaultStationRepositoryImpl() + } + + public static var testValue: StationInterface { + UnifiedDI.resolve(StationInterface.self) ?? DefaultStationRepositoryImpl() + } + + public static var previewValue: StationInterface = liveValue +} + +public extension DependencyValues { + var stationRepository: StationInterface { + get { self[StationRepositoryDependency.self] } + set { self[StationRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift index 65bc6a6..2ee7e69 100644 --- a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift @@ -32,7 +32,7 @@ public struct AuthUseCaseImpl: AuthInterface { return try await repository.logout() } - public func withDraw() async throws -> LogoutEntity { + public func withDraw() async throws -> Entity.LogoutEntity { return try await repository.withDraw() } diff --git a/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift new file mode 100644 index 0000000..64ba65d --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift @@ -0,0 +1,66 @@ +// +// StationUseCaseImpl.swift +// UseCase +// +// Created by Wonji Suh on 3/26/26. +// + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct StationUseCaseImpl: StationInterface { + @Dependency(\.stationRepository) var repository + + public init() {} + + public func fetchStations( + lat: Double, + lng: Double, + page: Int, + size: Int + ) async throws -> StationListEntity { + try await repository.fetchStations( + lat: lat, + lng: lng, + page: page, + size: size + ) + } + + public func fetchFavoriteStations( + page: Int, + size: Int + ) async throws -> FavoriteStationEntity { + try await repository.fetchFavoriteStations( + page: page, + size: size + ) + } + + public func addFavoriteStation( + stationID: Int + ) async throws -> FavoriteStationMutationEntity { + try await repository.addFavoriteStation(stationID: stationID) + } + + public func deleteFavoriteStation( + favoriteID: Int + ) async throws -> FavoriteStationMutationEntity { + try await repository.deleteFavoriteStation(favoriteID: favoriteID) + } +} + +extension StationUseCaseImpl: DependencyKey { + public static var liveValue: StationInterface = StationUseCaseImpl() + public static var testValue: StationInterface = StationUseCaseImpl() + public static var previewValue: StationInterface = StationUseCaseImpl() +} + +public extension DependencyValues { + var stationUseCase: StationInterface { + get { self[StationUseCaseImpl.self] } + set { self[StationUseCaseImpl.self] = newValue } + } +} From ca56518eee10e8dc90b4f9d502664f930204149e Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 23:47:43 +0900 Subject: [PATCH 20/27] =?UTF-8?q?feat:=20TrainStation=20UI=EC=99=80=20Stat?= =?UTF-8?q?ion=20API=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=EC=A6=90=EA=B2=A8?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TrainStation/Model/StationRowModel.swift | 33 ++- .../Reducer/TrainStationFeature.swift | 242 +++++++++++++++- .../TrainStation/View/TrainStationView.swift | 273 +++++++++++++----- 3 files changed, 474 insertions(+), 74 deletions(-) diff --git a/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift b/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift index 6214db6..5ad93aa 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift @@ -8,10 +8,33 @@ import Foundation import Entity -public struct StationRowModel: Identifiable, Equatable { +public struct StationRowModel: Identifiable, Equatable, Hashable { public let id: String - let station: Station - let badges: [String] - let distanceText: String? - let isFavorite: Bool + public let favoriteID: Int? + public let station: Station? + public let stationID: Int + public let stationName: String + public let badges: [String] + public let distanceText: String? + public let isFavorite: Bool + + public init( + id: String, + favoriteID: Int? = nil, + station: Station?, + stationID: Int, + stationName: String, + badges: [String], + distanceText: String?, + isFavorite: Bool + ) { + self.id = id + self.favoriteID = favoriteID + self.station = station + self.stationID = stationID + self.stationName = stationName + self.badges = badges + self.distanceText = distanceText + self.isFavorite = isFavorite + } } diff --git a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift index dcf4f63..99c4b69 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift @@ -7,15 +7,18 @@ import Foundation +import CoreLocation import ComposableArchitecture import DomainInterface +import UseCase import Utill import Entity @Reducer public struct TrainStationFeature { @Dependency(\.keychainManager) var keychainManager + @Dependency(\.stationUseCase) var stationUseCase public init() {} @@ -24,6 +27,12 @@ public struct TrainStationFeature { var searchText: String = "" var shouldShowFavoriteSection: Bool = false var selectedStation: Station + var favoriteItems: [FavoriteStationItemEntity] = [] + var favoriteRows: [StationRowModel] = [] + var nearbyRows: [StationRowModel] = [] + var majorRows: [StationRowModel] = [] + var isLoading: Bool = false + var errorMessage: String? public init(selectedStation: Station = .seoul) { self.selectedStation = selectedStation @@ -45,6 +54,7 @@ public struct TrainStationFeature { public enum View { case onAppear case stationTapped(Station) + case favoriteButtonTapped(StationRowModel) } @@ -52,11 +62,23 @@ public struct TrainStationFeature { //MARK: - AsyncAction 비동기 처리 액션 public enum AsyncAction: Equatable { case checkAccessToken + case fetchStations + case fetchFavoriteStations + case addFavoriteStation(Int) + case deleteFavoriteStation(Int) } //MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { case accessTokenChecked(Bool) + case fetchStationsResponse(StationListEntity) + case fetchStationsFailed(String) + case fetchFavoriteStationsResponse(FavoriteStationEntity) + case fetchFavoriteStationsFailed(String) + case addFavoriteStationResponse + case addFavoriteStationFailed(String) + case deleteFavoriteStationResponse + case deleteFavoriteStationFailed(String) } //MARK: - DelegateAction @@ -95,11 +117,22 @@ extension TrainStationFeature { ) -> Effect { switch action { case .onAppear: - return .send(.async(.checkAccessToken)) + state.isLoading = true + return .merge( + .send(.async(.checkAccessToken)), + .send(.async(.fetchStations)) + ) case .stationTapped(let station): state.selectedStation = station return .send(.delegate(.stationSelected(station))) + case .favoriteButtonTapped(let row): + guard state.shouldShowFavoriteSection else { return .none } + if row.isFavorite { + return .send(.async(.deleteFavoriteStation(row.favoriteID ?? row.stationID))) + } else { + return .send(.async(.addFavoriteStation(row.stationID))) + } } } @@ -114,6 +147,55 @@ extension TrainStationFeature { let hasAccessToken = !(accessToken?.isEmpty ?? true) await send(.inner(.accessTokenChecked(hasAccessToken))) } + case .fetchStations: + return .run { [stationUseCase] send in + let locationManager = await LocationPermissionManager.shared + let location = await MainActor.run { locationManager.currentLocation } + let lat = location?.coordinate.latitude ?? 37.5666805 + let lng = location?.coordinate.longitude ?? 126.9784147 + + do { + let entity = try await stationUseCase.fetchStations( + lat: lat, + lng: lng, + page: 1, + size: 30 + ) + await send(.inner(.fetchStationsResponse(entity))) + } catch { + await send(.inner(.fetchStationsFailed(error.localizedDescription))) + } + } + case .fetchFavoriteStations: + return .run { [stationUseCase] send in + do { + let entity = try await stationUseCase.fetchFavoriteStations( + page: 1, + size: 30 + ) + await send(.inner(.fetchFavoriteStationsResponse(entity))) + } catch { + await send(.inner(.fetchFavoriteStationsFailed(error.localizedDescription))) + } + } + case .addFavoriteStation(let stationID): + return .run { [stationUseCase] send in + do { + _ = try await stationUseCase.addFavoriteStation(stationID: stationID) + await send(.inner(.addFavoriteStationResponse)) + } catch { + await send(.inner(.addFavoriteStationFailed(error.localizedDescription))) + } + } + case .deleteFavoriteStation(let favoriteID): + return .run { [stationUseCase] send in + do { + _ = try await stationUseCase.deleteFavoriteStation(favoriteID: favoriteID) + await send(.inner(.deleteFavoriteStationResponse)) + } catch { + await send(.inner(.deleteFavoriteStationFailed(error.localizedDescription))) + } + } } } @@ -135,8 +217,166 @@ extension TrainStationFeature { case .accessTokenChecked(let shouldShowFavoriteSection): state.shouldShowFavoriteSection = shouldShowFavoriteSection return .none + case .fetchStationsResponse(let entity): + state.nearbyRows = makeNearbyRows(entity.nearbyStations) + state.majorRows = makeMajorRows(entity.stations.content) + if state.shouldShowFavoriteSection { + state.favoriteRows = makeFavoriteSummaryRows(entity.favoriteStations) + } + applyFavoriteState(state: &state) + state.isLoading = false + return .none + case .fetchStationsFailed(let message): + state.errorMessage = message + state.isLoading = false + return .none + case .fetchFavoriteStationsResponse(let entity): + state.favoriteItems = entity.items + applyFavoriteState(state: &state) + return .none + case .fetchFavoriteStationsFailed(let message): + if state.favoriteRows.isEmpty { + state.errorMessage = message + } + return .none + case .addFavoriteStationResponse: + return .send(.async(.fetchFavoriteStations)) + case .addFavoriteStationFailed(let message): + state.errorMessage = message + return .none + case .deleteFavoriteStationResponse: + return .send(.async(.fetchFavoriteStations)) + case .deleteFavoriteStationFailed(let message): + state.errorMessage = message + return .none } } } extension TrainStationFeature.State: Hashable {} + +private extension TrainStationFeature { + func makeNearbyRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { + stations.map { station in + let normalizedName = normalizedStationName(station.name) + return StationRowModel( + id: "nearby-\(station.stationID)", + favoriteID: nil, + station: Station(displayName: normalizedName), + stationID: station.stationID, + stationName: normalizedName, + badges: station.lines, + distanceText: "2.3km", + isFavorite: false + ) + } + } + + func makeMajorRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { + stations.map { station in + let normalizedName = normalizedStationName(station.name) + return StationRowModel( + id: "station-\(station.stationID)", + favoriteID: nil, + station: Station(displayName: normalizedName), + stationID: station.stationID, + stationName: normalizedName, + badges: station.lines, + distanceText: nil, + isFavorite: false + ) + } + } + + func makeFavoriteRows( + _ favorites: [FavoriteStationItemEntity], + stationRows: [StationRowModel] + ) -> [StationRowModel] { + favorites.map { favorite in + let normalizedName = normalizedStationName(favorite.stationName) + let matchedLines = stationRows.first(where: { + normalizedStationName($0.stationName) == normalizedName + })?.badges ?? [] + + return StationRowModel( + id: "favorite-\(favorite.favoriteID)", + favoriteID: favorite.favoriteID, + station: Station(displayName: normalizedName), + stationID: favorite.stationID, + stationName: normalizedName, + badges: matchedLines, + distanceText: nil, + isFavorite: true + ) + } + } + + func makeFavoriteSummaryRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { + Array( + Dictionary( + uniqueKeysWithValues: stations.map { station in + (normalizedStationName(station.name), station) + } + ).values + ) + .sorted { $0.name < $1.name } + .map { station in + let normalizedName = normalizedStationName(station.name) + return StationRowModel( + id: "favorite-summary-\(station.stationID)", + favoriteID: nil, + station: Station(displayName: normalizedName), + stationID: station.stationID, + stationName: normalizedName, + badges: station.lines, + distanceText: nil, + isFavorite: true + ) + } + } + + func applyFavoriteState(state: inout State) { + let favoriteNameMap = Dictionary( + uniqueKeysWithValues: state.favoriteItems.map { + (normalizedStationName($0.stationName), $0.favoriteID) + } + ) + + let stationRows = state.majorRows + state.nearbyRows + state.favoriteRows = makeFavoriteRows(state.favoriteItems, stationRows: stationRows) + + state.nearbyRows = state.nearbyRows.map { row in + let favoriteID = favoriteNameMap[normalizedStationName(row.stationName)] + return StationRowModel( + id: row.id, + favoriteID: favoriteID, + station: row.station, + stationID: row.stationID, + stationName: row.stationName, + badges: row.badges, + distanceText: row.distanceText, + isFavorite: favoriteID != nil + ) + } + + state.majorRows = state.majorRows.map { row in + let favoriteID = favoriteNameMap[normalizedStationName(row.stationName)] + return StationRowModel( + id: row.id, + favoriteID: favoriteID, + station: row.station, + stationID: row.stationID, + stationName: row.stationName, + badges: row.badges, + distanceText: row.distanceText, + isFavorite: favoriteID != nil + ) + } + } + + func normalizedStationName(_ name: String) -> String { + name + .replacingOccurrences(of: "역", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift b/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift index a9e8d2f..d850b58 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift @@ -24,40 +24,44 @@ public struct TrainStationView: View { public var body: some View { VStack(alignment: .leading, spacing: 0) { - headerView() - searchFieldView() + if store.isLoading { + stationSkeletonView() + } else { + headerView() + searchFieldView() + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if store.shouldShowFavoriteSection { + stationSectionView( + title: "즐겨찾기", + systemIcon: "star.fill", + assetIcon: nil, + iconColor: .gray550, + stations: filteredFavoriteStations + ) + } - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - if store.shouldShowFavoriteSection { stationSectionView( - title: "즐겨찾기", - systemIcon: "star.fill", - assetIcon: nil, + title: "가까운 역", + systemIcon: nil, + assetIcon: .mapSharp, iconColor: .gray550, - stations: filteredFavoriteStations + stations: filteredNearbyStations ) - } - - stationSectionView( - title: "가까운 역", - systemIcon: nil, - assetIcon: .mapSharp, - iconColor: .gray550, - stations: filteredNearbyStations - ) - stationSectionView( - title: "주요 역", - systemIcon: nil, - assetIcon: .subway, - iconColor: .gray550, - stations: filteredMajorStations - ) + stationSectionView( + title: "주요 역", + systemIcon: nil, + assetIcon: .subway, + iconColor: .gray550, + stations: filteredMajorStations + ) + } + .padding(.bottom, 16) } - .padding(.bottom, 16) + .scrollIndicators(.hidden) } - .scrollIndicators(.hidden) } .background(.staticWhite) .onAppear { @@ -68,15 +72,15 @@ public struct TrainStationView: View { extension TrainStationView { private var filteredFavoriteStations: [StationRowModel] { - filterRows(favoriteStations) + filterRows(store.favoriteRows) } private var filteredNearbyStations: [StationRowModel] { - filterRows(nearbyStations) + filterRows(store.nearbyRows) } private var filteredMajorStations: [StationRowModel] { - filterRows(majorStations) + filterRows(store.majorRows) } private func filterRows(_ rows: [StationRowModel]) -> [StationRowModel] { @@ -85,7 +89,7 @@ extension TrainStationView { } return rows.filter { - $0.station.displayName.localizedCaseInsensitiveContains(store.searchText) + $0.stationName.localizedCaseInsensitiveContains(store.searchText) || $0.badges.joined(separator: " ").localizedCaseInsensitiveContains(store.searchText) } } @@ -185,13 +189,15 @@ extension TrainStationView { @ViewBuilder private func stationRowView(_ row: StationRowModel) -> some View { - Button { - store.send(.view(.stationTapped(row.station))) - modalDismiss() - } label: { - HStack(spacing: 12) { + HStack(spacing: 12) { + Button { + if let station = row.station { + store.send(.view(.stationTapped(station))) + modalDismiss() + } + } label: { HStack(spacing: 8) { - Text(row.station.displayName) + Text(row.stationName) .pretendardCustomFont(textStyle: .titleRegular) .foregroundStyle(.staticBlack) @@ -209,18 +215,22 @@ extension TrainStationView { .pretendardCustomFont(textStyle: .caption) .foregroundStyle(.gray500) } + } + .buttonStyle(.plain) + Button { + store.send(.view(.favoriteButtonTapped(row))) + } label: { Image(systemName: row.isFavorite ? "star.fill" : "star") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(row.isFavorite ? .orange700 : .gray550) .frame(width: 20, height: 20) } - .padding(.leading, 20) - .padding(.trailing, 24) - .padding(.vertical, 18) - .contentShape(Rectangle()) + .buttonStyle(.plain) } - .buttonStyle(.plain) + .padding(.leading, 20) + .padding(.trailing, 24) + .padding(.vertical, 18) } @ViewBuilder @@ -233,36 +243,163 @@ extension TrainStationView { .background(.gray200) .clipShape(Capsule()) } -} -private extension TrainStationView { - var favoriteStations: [StationRowModel] { - [ - .init(id: "favorite-dongdaegu-1", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: true), - .init(id: "favorite-dongdaegu-2", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: true) - ] + @ViewBuilder + private func stationSkeletonView() -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 44, height: 15) + .skeletonShimmer() + .padding(.top, 20) + .padding(.horizontal, 20) + .padding(.bottom, 12) + + RoundedRectangle(cornerRadius: 18) + .fill(.gray200) + .frame(height: 60) + .skeletonShimmer() + .padding(.horizontal, 20) + .padding(.bottom, 18) + + skeletonSectionHeader() + .padding(.top, 10) + skeletonRow(showDistance: false) + skeletonDivider() + skeletonRow(showDistance: true) + skeletonDivider() + + skeletonSectionHeader() + .padding(.top, 10) + skeletonRow(showDistance: false, badgeCount: 3) + skeletonDivider() + skeletonRow(showDistance: false, badgeCount: 3) + skeletonDivider() + skeletonRow(showDistance: false) + skeletonDivider() + skeletonRow(showDistance: false) + skeletonDivider() + skeletonRow(showDistance: false) + skeletonDivider() + skeletonRow(showDistance: false) + skeletonDivider() + skeletonRow(showDistance: false) + skeletonDivider() + skeletonRow(showDistance: false) + skeletonDivider() + skeletonRow(showDistance: false) + } + .padding(.bottom, 20) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func skeletonSectionHeader() -> some View { + HStack(spacing: 8) { + Circle() + .fill(.gray200) + .frame(width: 16, height: 16) + .skeletonShimmer() + + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 56, height: 14) + .skeletonShimmer() + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 16) + } + + @ViewBuilder + private func skeletonRow( + showDistance: Bool, + badgeCount: Int = 2 + ) -> some View { + HStack(spacing: 12) { + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 40, height: 18) + .skeletonShimmer() + + HStack(spacing: 8) { + ForEach(0.. some View { + Rectangle() + .fill(.gray200) + .frame(height: 1) + .padding(.leading, 20) + .padding(.trailing, 24) } +} - var nearbyStations: [StationRowModel] { - [ - .init(id: "nearby-dongdaegu-1", station: Station.dongdaegu, badges: ["경부선"], distanceText: "2.3km", isFavorite: false), - .init(id: "nearby-dongdaegu-2", station: Station.dongdaegu, badges: ["경부선"], distanceText: "2.3km", isFavorite: false) - ] +private extension View { + func skeletonShimmer() -> some View { + modifier(TrainStationSkeletonShimmerModifier()) } +} - var majorStations: [StationRowModel] { - [ - .init(id: "major-dongdaegu-1", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), - .init(id: "major-dongdaegu-2", station: Station.dongdaegu, badges: ["경부선", "경전선"], distanceText: nil, isFavorite: false), - .init(id: "major-dongdaegu-3", station: Station.dongdaegu, badges: ["경부선", "강릉선"], distanceText: nil, isFavorite: false), - .init(id: "major-dongdaegu-4", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), - .init(id: "major-dongdaegu-5", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), - .init(id: "major-dongdaegu-6", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), - .init(id: "major-dongdaegu-7", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), - .init(id: "major-dongdaegu-8", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), - .init(id: "major-dongdaegu-9", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), - .init(id: "major-dongdaegu-10", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false), - .init(id: "major-dongdaegu-11", station: Station.dongdaegu, badges: ["경부선"], distanceText: nil, isFavorite: false) - ] +private struct TrainStationSkeletonShimmerModifier: ViewModifier { + @State private var isAnimating = false + + func body(content: Content) -> some View { + content + .overlay { + GeometryReader { geometry in + LinearGradient( + colors: [ + .white.opacity(0), + .white.opacity(0.28), + .white.opacity(0) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .frame(width: geometry.size.width * 0.55) + .offset(x: isAnimating ? geometry.size.width * 1.25 : -geometry.size.width * 0.8) + } + .clipped() + } + .mask(content) + .onAppear { + guard !isAnimating else { return } + withAnimation( + .easeInOut(duration: 1.0) + .repeatForever(autoreverses: false) + ) { + isAnimating = true + } + } } } From 94d4f4e935f0f6fbe1013102859bf1b5c54fe071 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 26 Mar 2026 23:49:26 +0900 Subject: [PATCH 21/27] =?UTF-8?q?feat:=20Station=20DI=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/Di/DiRegister.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index ebd7513..fd55ac7 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -46,6 +46,8 @@ public final class AppDIManager { .register(ProfileInterface.self) { ProfileRepositoryImpl() } // MARK: - 히스토리 .register(HistoryInterface.self) { HistoryRepositoryImpl() } + // MARK: - 역 + .register(StationInterface.self) { StationRepositoryImpl() } From 16b59fa0e1b89ef0e684f9800b2de484b1ec8587 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 27 Mar 2026 00:00:42 +0900 Subject: [PATCH 22/27] =?UTF-8?q?fix:=20Station=20API=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../API/Sources/API/Station/StationAPI.swift | 6 +-- .../Station/DTO/FavoriteStationDTOModel.swift | 38 +++++++++++++ .../Sources/Station/DTO/StationDTOModel.swift | 53 +++++++++++++++++++ .../Mapper/FavoriteStationDTOModel+.swift | 1 + .../Sources/Station/StationService.swift | 7 +-- .../Station/FavoriteStationEntity.swift | 3 ++ .../Reducer/TrainStationFeature.swift | 31 ++++++++--- 7 files changed, 123 insertions(+), 16 deletions(-) diff --git a/Projects/Data/API/Sources/API/Station/StationAPI.swift b/Projects/Data/API/Sources/API/Station/StationAPI.swift index 6139ff5..5a3c5b8 100644 --- a/Projects/Data/API/Sources/API/Station/StationAPI.swift +++ b/Projects/Data/API/Sources/API/Station/StationAPI.swift @@ -18,11 +18,11 @@ public enum StationAPI { case .allStation: return "" case .favoriteStation: - return "" + return "/favorites" case .addFavoriteStation: - return "" + return "/favorites" case .deleteFavoriteStation(let deleteStationId): - return "/\(deleteStationId)" + return "/favorites/\(deleteStationId)" } } } diff --git a/Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift b/Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift index dca646c..44b42de 100644 --- a/Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift +++ b/Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift @@ -18,6 +18,17 @@ public struct FavoriteStationPageResponseDTO: Decodable, Equatable { public let first: Bool public let last: Bool + enum CodingKeys: String, CodingKey { + case content + case totalElements + case totalPages + case size + case number + case first + case last + case hasNext + } + public init( content: [FavoriteStationItemResponseDTO], totalElements: Int, @@ -35,6 +46,29 @@ public struct FavoriteStationPageResponseDTO: Decodable, Equatable { self.first = first self.last = last } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let content = try container.decode([FavoriteStationItemResponseDTO].self, forKey: .content) + let totalElements = try container.decode(Int.self, forKey: .totalElements) + let totalPages = try container.decode(Int.self, forKey: .totalPages) + let size = try container.decode(Int.self, forKey: .size) + let number = try container.decode(Int.self, forKey: .number) + + let hasNext = try container.decodeIfPresent(Bool.self, forKey: .hasNext) ?? false + let first = try container.decodeIfPresent(Bool.self, forKey: .first) ?? (number == 0) + let last = try container.decodeIfPresent(Bool.self, forKey: .last) ?? !hasNext + + self.init( + content: content, + totalElements: totalElements, + totalPages: totalPages, + size: size, + number: number, + first: first, + last: last + ) + } } public struct FavoriteStationItemResponseDTO: Decodable, Equatable { @@ -42,6 +76,7 @@ public struct FavoriteStationItemResponseDTO: Decodable, Equatable { public let stationID: Int public let stationName: String public let visitCount: Int + public let totalVisitMinutes: Int public let createdAt: String enum CodingKeys: String, CodingKey { @@ -49,6 +84,7 @@ public struct FavoriteStationItemResponseDTO: Decodable, Equatable { case stationID = "stationId" case stationName case visitCount + case totalVisitMinutes case createdAt } @@ -57,12 +93,14 @@ public struct FavoriteStationItemResponseDTO: Decodable, Equatable { stationID: Int, stationName: String, visitCount: Int, + totalVisitMinutes: Int, createdAt: String ) { self.favoriteID = favoriteID self.stationID = stationID self.stationName = stationName self.visitCount = visitCount + self.totalVisitMinutes = totalVisitMinutes self.createdAt = createdAt } } diff --git a/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift b/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift index 8c5bd70..355a65a 100644 --- a/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift +++ b/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift @@ -14,6 +14,12 @@ public struct StationListResponseDTO: Decodable, Equatable { public let nearbyStations: [StationSummaryResponseDTO] public let stations: StationPageResponseDTO + enum CodingKeys: String, CodingKey { + case favoriteStations + case nearbyStations + case stations + } + public init( favoriteStations: [StationSummaryResponseDTO], nearbyStations: [StationSummaryResponseDTO], @@ -23,6 +29,13 @@ public struct StationListResponseDTO: Decodable, Equatable { self.nearbyStations = nearbyStations self.stations = stations } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.favoriteStations = (try? container.decode([StationSummaryResponseDTO].self, forKey: .favoriteStations)) ?? [] + self.nearbyStations = (try? container.decode([StationSummaryResponseDTO].self, forKey: .nearbyStations)) ?? [] + self.stations = try container.decode(StationPageResponseDTO.self, forKey: .stations) + } } public struct StationSummaryResponseDTO: Decodable, Equatable { @@ -58,6 +71,19 @@ public struct StationPageResponseDTO: Decodable, Equatable { public let number: Int public let empty: Bool + enum CodingKeys: String, CodingKey { + case content + case totalElements + case totalPages + case last + case first + case numberOfElements + case size + case number + case empty + case hasNext + } + public init( content: [StationSummaryResponseDTO], totalElements: Int, @@ -79,4 +105,31 @@ public struct StationPageResponseDTO: Decodable, Equatable { self.number = number self.empty = empty } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let content = try container.decode([StationSummaryResponseDTO].self, forKey: .content) + let totalElements = try container.decode(Int.self, forKey: .totalElements) + let totalPages = try container.decode(Int.self, forKey: .totalPages) + let size = try container.decode(Int.self, forKey: .size) + let number = try container.decode(Int.self, forKey: .number) + + let hasNext = try container.decodeIfPresent(Bool.self, forKey: .hasNext) ?? false + let first = try container.decodeIfPresent(Bool.self, forKey: .first) ?? (number == 0) + let last = try container.decodeIfPresent(Bool.self, forKey: .last) ?? !hasNext + let numberOfElements = try container.decodeIfPresent(Int.self, forKey: .numberOfElements) ?? content.count + let empty = try container.decodeIfPresent(Bool.self, forKey: .empty) ?? content.isEmpty + + self.init( + content: content, + totalElements: totalElements, + totalPages: totalPages, + last: last, + first: first, + numberOfElements: numberOfElements, + size: size, + number: number, + empty: empty + ) + } } diff --git a/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift b/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift index cb10600..ad665e5 100644 --- a/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift +++ b/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift @@ -28,6 +28,7 @@ public extension FavoriteStationItemResponseDTO { stationID: stationID, stationName: stationName, visitCount: visitCount, + totalVisitMinutes: totalVisitMinutes, createdAt: createdAt ) } diff --git a/Projects/Data/Service/Sources/Station/StationService.swift b/Projects/Data/Service/Sources/Station/StationService.swift index 56a1eb0..81d3e36 100644 --- a/Projects/Data/Service/Sources/Station/StationService.swift +++ b/Projects/Data/Service/Sources/Station/StationService.swift @@ -24,12 +24,7 @@ extension StationService: BaseTargetType { public typealias Domain = TimeSpotDomain public var domain: TimeSpotDomain { - switch self { - case .allStation: - return .station - case .favoriteStation, .addFavoriteStation, .deleteFavoriteStation: - return .favorite - } + return .station } public var urlPath: String { diff --git a/Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift b/Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift index 7510b5e..93fa260 100644 --- a/Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift +++ b/Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift @@ -50,6 +50,7 @@ public struct FavoriteStationItemEntity: Equatable, Hashable, Identifiable { public let stationID: Int public let stationName: String public let visitCount: Int + public let totalVisitMinutes: Int public let createdAt: String public var id: Int { favoriteID } @@ -59,12 +60,14 @@ public struct FavoriteStationItemEntity: Equatable, Hashable, Identifiable { stationID: Int, stationName: String, visitCount: Int, + totalVisitMinutes: Int, createdAt: String ) { self.favoriteID = favoriteID self.stationID = stationID self.stationName = stationName self.visitCount = visitCount + self.totalVisitMinutes = totalVisitMinutes self.createdAt = createdAt } } diff --git a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift index 99c4b69..609f568 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift @@ -129,7 +129,15 @@ extension TrainStationFeature { case .favoriteButtonTapped(let row): guard state.shouldShowFavoriteSection else { return .none } if row.isFavorite { - return .send(.async(.deleteFavoriteStation(row.favoriteID ?? row.stationID))) + let resolvedFavoriteID = row.favoriteID ?? state.favoriteItems.first(where: { + normalizedStationName($0.stationName) == normalizedStationName(row.stationName) + })?.favoriteID + + guard let favoriteID = resolvedFavoriteID else { + return .none + } + + return .send(.async(.deleteFavoriteStation(favoriteID))) } else { return .send(.async(.addFavoriteStation(row.stationID))) } @@ -216,7 +224,8 @@ extension TrainStationFeature { switch action { case .accessTokenChecked(let shouldShowFavoriteSection): state.shouldShowFavoriteSection = shouldShowFavoriteSection - return .none + guard shouldShowFavoriteSection else { return .none } + return .send(.async(.fetchFavoriteStations)) case .fetchStationsResponse(let entity): state.nearbyRows = makeNearbyRows(entity.nearbyStations) state.majorRows = makeMajorRows(entity.stations.content) @@ -235,17 +244,24 @@ extension TrainStationFeature { applyFavoriteState(state: &state) return .none case .fetchFavoriteStationsFailed(let message): - if state.favoriteRows.isEmpty { + // favorites API가 실패해도 allStation.favoriteStations로 이미 렌더 가능하면 화면은 유지 + if state.favoriteRows.isEmpty && state.favoriteItems.isEmpty { state.errorMessage = message } return .none case .addFavoriteStationResponse: - return .send(.async(.fetchFavoriteStations)) + return .merge( + .send(.async(.fetchFavoriteStations)), + .send(.async(.fetchStations)) + ) case .addFavoriteStationFailed(let message): state.errorMessage = message return .none case .deleteFavoriteStationResponse: - return .send(.async(.fetchFavoriteStations)) + return .merge( + .send(.async(.fetchFavoriteStations)), + .send(.async(.fetchStations)) + ) case .deleteFavoriteStationFailed(let message): state.errorMessage = message return .none @@ -314,9 +330,10 @@ private extension TrainStationFeature { func makeFavoriteSummaryRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { Array( Dictionary( - uniqueKeysWithValues: stations.map { station in + stations.map { station in (normalizedStationName(station.name), station) - } + }, + uniquingKeysWith: { first, _ in first } ).values ) .sorted { $0.name < $1.name } From c7672e82d7af5f6284f8eca976e07c0108e1f5cb Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 27 Mar 2026 01:27:09 +0900 Subject: [PATCH 23/27] refactor: improve station API architecture and UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공개/인증 역 엔드포인트 분리 - 비동기 작업 취소 기능 추가 - 검색 정규화로 검색 성능 향상 - 정렬 및 스타일링으로 UI 레이아웃 개선 --- .../Sources/API/Domain/TimeSpotDomain.swift | 3 - .../Sources/History/DTO/HistoryDTOModel.swift | 34 +++++ .../Station/StationRepositoryImpl.swift | 17 ++- .../DefaultStationRepositoryImpl.swift | 2 + .../Entity/Sources/TrainStation/Station.swift | 133 ++++++++++++++++++ .../Sources/Main/Reducer/HomeFeature.swift | 18 ++- .../Home/Sources/Main/View/HomeView.swift | 6 +- .../Reducer/TrainStationFeature.swift | 22 ++- .../TrainStation/View/TrainStationView.swift | 51 +++++-- .../Components/TravelHistoryCardView.swift | 5 +- .../Ui/Modal/CustomModalModifier.swift | 4 +- 11 files changed, 262 insertions(+), 33 deletions(-) diff --git a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift index c723de9..c1ee8c3 100644 --- a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift +++ b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift @@ -15,7 +15,6 @@ public enum TimeSpotDomain { case profile case history case station - case favorite } extension TimeSpotDomain: DomainType { @@ -35,8 +34,6 @@ extension TimeSpotDomain: DomainType { return "api/v1/histories" case .station: return "api/v1/stations" - case .favorite: - return "api/v1/favorites" } } } diff --git a/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift b/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift index 84c6b6f..676baa9 100644 --- a/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift +++ b/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift @@ -18,6 +18,17 @@ public struct HistoryPageResponseDTO: Decodable, Equatable { public let first: Bool public let last: Bool + enum CodingKeys: String, CodingKey { + case content + case totalElements + case totalPages + case size + case number + case first + case last + case hasNext + } + public init( content: [HistoryItemResponseDTO], totalElements: Int, @@ -35,6 +46,29 @@ public struct HistoryPageResponseDTO: Decodable, Equatable { self.first = first self.last = last } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let content = try container.decode([HistoryItemResponseDTO].self, forKey: .content) + let totalElements = try container.decode(Int.self, forKey: .totalElements) + let totalPages = try container.decode(Int.self, forKey: .totalPages) + let size = try container.decode(Int.self, forKey: .size) + let number = try container.decode(Int.self, forKey: .number) + + let hasNext = try container.decodeIfPresent(Bool.self, forKey: .hasNext) ?? false + let first = try container.decodeIfPresent(Bool.self, forKey: .first) ?? (number == 0) + let last = try container.decodeIfPresent(Bool.self, forKey: .last) ?? !hasNext + + self.init( + content: content, + totalElements: totalElements, + totalPages: totalPages, + size: size, + number: number, + first: first, + last: last + ) + } } public struct HistoryItemResponseDTO: Decodable, Equatable { diff --git a/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift b/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift index ded0d6b..76d6dab 100644 --- a/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift @@ -14,12 +14,15 @@ import Service import AsyncMoya public final class StationRepositoryImpl: StationInterface, @unchecked Sendable { - private let provider: MoyaProvider + private let authorizedProvider: MoyaProvider + private let publicProvider: MoyaProvider public init( - provider: MoyaProvider = MoyaProvider.authorized + authorizedProvider: MoyaProvider = MoyaProvider.authorized, + publicProvider: MoyaProvider = MoyaProvider() ) { - self.provider = provider + self.authorizedProvider = authorizedProvider + self.publicProvider = publicProvider } public func fetchStations( @@ -35,7 +38,7 @@ public final class StationRepositoryImpl: StationInterface, @unchecked Sendable size: size, sort: "stationName,ASC" ) - let dto: StationDTOModel = try await provider.request(.allStation(body: body)) + let dto: StationDTOModel = try await publicProvider.request(.allStation(body: body)) return dto.data.toDomain() } @@ -48,7 +51,7 @@ public final class StationRepositoryImpl: StationInterface, @unchecked Sendable size: size, sort: "stationName,ASC" ) - let dto: FavoriteStationDTOModel = try await provider.request(.favoriteStation(body: body)) + let dto: FavoriteStationDTOModel = try await authorizedProvider.request(.favoriteStation(body: body)) return dto.data.toDomain() } @@ -56,14 +59,14 @@ public final class StationRepositoryImpl: StationInterface, @unchecked Sendable stationID: Int ) async throws -> FavoriteStationMutationEntity { let body: AddFavoriteStationRequest = .init(stationID: stationID) - let dto: FavoriteStationMutationDTOModel = try await provider.request(.addFavoriteStation(body: body)) + let dto: FavoriteStationMutationDTOModel = try await authorizedProvider.request(.addFavoriteStation(body: body)) return dto.toDomain() } public func deleteFavoriteStation( favoriteID: Int ) async throws -> FavoriteStationMutationEntity { - let dto: FavoriteStationMutationDTOModel = try await provider.request( + let dto: FavoriteStationMutationDTOModel = try await authorizedProvider.request( .deleteFavoriteStation(deleteStationId: favoriteID) ) return dto.toDomain() diff --git a/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift index 7d212c0..dda33dd 100644 --- a/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift @@ -59,6 +59,7 @@ final public class DefaultStationRepositoryImpl: StationInterface { stationID: 10, stationName: "서울역", visitCount: 5, + totalVisitMinutes: 0, createdAt: "2024-03-24T16:00:00" ), .init( @@ -66,6 +67,7 @@ final public class DefaultStationRepositoryImpl: StationInterface { stationID: 20, stationName: "강남역", visitCount: 3, + totalVisitMinutes: 0, createdAt: "2024-03-23T10:30:00" ) ] diff --git a/Projects/Domain/Entity/Sources/TrainStation/Station.swift b/Projects/Domain/Entity/Sources/TrainStation/Station.swift index c7c5b26..ca6122c 100644 --- a/Projects/Domain/Entity/Sources/TrainStation/Station.swift +++ b/Projects/Domain/Entity/Sources/TrainStation/Station.swift @@ -10,11 +10,30 @@ import Foundation public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { case seoul case yongsan + case gwangmyeong + case cheonanAsan + case osong + case gimcheonGumi + case gyeongju + case ulsan case busan + case pohang + case seodaejeon + case iksan + case jeongeup + case gwangjuSongjeong + case naju + case mokpo + case jeonju + case namwon + case suncheon + case yeosuExpo case dongdaegu case daejeon case gangneung case cheongnyangri + case manjong + case pyeongchang public var id: String { rawValue } @@ -24,8 +43,42 @@ public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { return "서울" case .yongsan: return "용산" + case .gwangmyeong: + return "광명" + case .cheonanAsan: + return "천안아산" + case .osong: + return "오송" + case .gimcheonGumi: + return "김천(구미)" + case .gyeongju: + return "경주" + case .ulsan: + return "울산" case .busan: return "부산" + case .pohang: + return "포항" + case .seodaejeon: + return "서대전" + case .iksan: + return "익산" + case .jeongeup: + return "정읍" + case .gwangjuSongjeong: + return "광주송정" + case .naju: + return "나주" + case .mokpo: + return "목포" + case .jeonju: + return "전주" + case .namwon: + return "남원" + case .suncheon: + return "순천" + case .yeosuExpo: + return "여수EXPO" case .dongdaegu: return "동대구" case .daejeon: @@ -34,6 +87,10 @@ public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { return "강릉" case .cheongnyangri: return "청량리" + case .manjong: + return "만종" + case .pyeongchang: + return "평창" } } @@ -43,8 +100,42 @@ public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { return "SEOUL" case .yongsan: return "YONGSAN" + case .gwangmyeong: + return "광명" + case .cheonanAsan: + return "천안아산" + case .osong: + return "오송" + case .gimcheonGumi: + return "김천(구미)" + case .gyeongju: + return "경주" + case .ulsan: + return "울산" case .busan: return "BUSAN" + case .pohang: + return "포항" + case .seodaejeon: + return "서대전" + case .iksan: + return "익산" + case .jeongeup: + return "정읍" + case .gwangjuSongjeong: + return "광주송정" + case .naju: + return "나주" + case .mokpo: + return "목포" + case .jeonju: + return "전주" + case .namwon: + return "남원" + case .suncheon: + return "순천" + case .yeosuExpo: + return "여수EXPO" case .dongdaegu: return "DONGDAEGU" case .daejeon: @@ -53,6 +144,10 @@ public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { return "GANGNEUNG" case .cheongnyangri: return "CHEONGNYANGNI" + case .manjong: + return "만종" + case .pyeongchang: + return "평창" } } @@ -66,8 +161,42 @@ public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { self = .seoul case "용산": self = .yongsan + case "광명": + self = .gwangmyeong + case "천안아산": + self = .cheonanAsan + case "오송": + self = .osong + case "김천(구미)": + self = .gimcheonGumi + case "경주": + self = .gyeongju + case "울산": + self = .ulsan case "부산": self = .busan + case "포항": + self = .pohang + case "서대전": + self = .seodaejeon + case "익산": + self = .iksan + case "정읍": + self = .jeongeup + case "광주송정": + self = .gwangjuSongjeong + case "나주": + self = .naju + case "목포": + self = .mokpo + case "전주": + self = .jeonju + case "남원": + self = .namwon + case "순천": + self = .suncheon + case "여수EXPO": + self = .yeosuExpo case "동대구": self = .dongdaegu case "대전": @@ -76,6 +205,10 @@ public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { self = .gangneung case "청량리": self = .cheongnyangri + case "만종": + self = .manjong + case "평창": + self = .pyeongchang default: return nil } diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index daa21df..5c212d0 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -187,14 +187,22 @@ extension HomeFeature { $0.travelID = station.id $0.travelStationName = station.displayName } - guard state.shouldShowDepartureWarningToast else { - return .none - } - return .send(.inner(.showDepartureWarningToast)) + return .merge( + .cancel(id: TrainStationFeature.CancelID.checkAccessToken), + .cancel(id: TrainStationFeature.CancelID.fetchStations), + .cancel(id: TrainStationFeature.CancelID.fetchFavoriteStations), + .cancel(id: TrainStationFeature.CancelID.favoriteMutation), + state.shouldShowDepartureWarningToast ? .send(.inner(.showDepartureWarningToast)) : .none + ) case .dismiss: state.isSelected = false - return .none + return .merge( + .cancel(id: TrainStationFeature.CancelID.checkAccessToken), + .cancel(id: TrainStationFeature.CancelID.fetchStations), + .cancel(id: TrainStationFeature.CancelID.fetchFavoriteStations), + .cancel(id: TrainStationFeature.CancelID.favoriteMutation) + ) default: return .none diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 0fdcaa7..82649a1 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -182,8 +182,8 @@ extension HomeView { .buttonStyle(.plain) .frame(maxWidth: .infinity) } - .padding(.horizontal, 8) - .padding(.top, 4) + .padding(.horizontal, 24) + .padding(.top, 8) } @ViewBuilder @@ -226,7 +226,7 @@ extension HomeView { .frame(maxWidth: .infinity) .frame(height: 77) .background(backgroundColor) - .cornerRadius(32) + .cornerRadius(28) } diff --git a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift index 609f568..025bef9 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift @@ -22,6 +22,13 @@ public struct TrainStationFeature { public init() {} + public enum CancelID: Hashable { + case checkAccessToken + case fetchStations + case fetchFavoriteStations + case favoriteMutation + } + @ObservableState public struct State: Equatable { var searchText: String = "" @@ -155,6 +162,7 @@ extension TrainStationFeature { let hasAccessToken = !(accessToken?.isEmpty ?? true) await send(.inner(.accessTokenChecked(hasAccessToken))) } + .cancellable(id: CancelID.checkAccessToken) case .fetchStations: return .run { [stationUseCase] send in let locationManager = await LocationPermissionManager.shared @@ -174,6 +182,7 @@ extension TrainStationFeature { await send(.inner(.fetchStationsFailed(error.localizedDescription))) } } + .cancellable(id: CancelID.fetchStations) case .fetchFavoriteStations: return .run { [stationUseCase] send in do { @@ -186,6 +195,7 @@ extension TrainStationFeature { await send(.inner(.fetchFavoriteStationsFailed(error.localizedDescription))) } } + .cancellable(id: CancelID.fetchFavoriteStations) case .addFavoriteStation(let stationID): return .run { [stationUseCase] send in do { @@ -195,6 +205,7 @@ extension TrainStationFeature { await send(.inner(.addFavoriteStationFailed(error.localizedDescription))) } } + .cancellable(id: CancelID.favoriteMutation, cancelInFlight: true) case .deleteFavoriteStation(let favoriteID): return .run { [stationUseCase] send in do { @@ -204,6 +215,7 @@ extension TrainStationFeature { await send(.inner(.deleteFavoriteStationFailed(error.localizedDescription))) } } + .cancellable(id: CancelID.favoriteMutation, cancelInFlight: true) } } @@ -273,7 +285,7 @@ extension TrainStationFeature.State: Hashable {} private extension TrainStationFeature { func makeNearbyRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { - stations.map { station in + Array(stations.sorted { normalizedStationName($0.name) < normalizedStationName($1.name) }.prefix(3)).map { station in let normalizedName = normalizedStationName(station.name) return StationRowModel( id: "nearby-\(station.stationID)", @@ -289,7 +301,9 @@ private extension TrainStationFeature { } func makeMajorRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { - stations.map { station in + stations + .sorted { normalizedStationName($0.name) < normalizedStationName($1.name) } + .map { station in let normalizedName = normalizedStationName(station.name) return StationRowModel( id: "station-\(station.stationID)", @@ -308,7 +322,9 @@ private extension TrainStationFeature { _ favorites: [FavoriteStationItemEntity], stationRows: [StationRowModel] ) -> [StationRowModel] { - favorites.map { favorite in + favorites + .sorted { normalizedStationName($0.stationName) < normalizedStationName($1.stationName) } + .map { favorite in let normalizedName = normalizedStationName(favorite.stationName) let matchedLines = stationRows.first(where: { normalizedStationName($0.stationName) == normalizedName diff --git a/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift b/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift index d850b58..aecad03 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift @@ -60,10 +60,13 @@ public struct TrainStationView: View { } .padding(.bottom, 16) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .scrollIndicators(.hidden) } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(.staticWhite) + .ignoresSafeArea(.keyboard, edges: .bottom) .onAppear { store.send(.view(.onAppear)) } @@ -84,16 +87,24 @@ extension TrainStationView { } private func filterRows(_ rows: [StationRowModel]) -> [StationRowModel] { - guard !store.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + let query = normalizedSearchText(store.searchText) + + guard !query.isEmpty else { return rows } return rows.filter { - $0.stationName.localizedCaseInsensitiveContains(store.searchText) - || $0.badges.joined(separator: " ").localizedCaseInsensitiveContains(store.searchText) + normalizedSearchText($0.stationName).localizedCaseInsensitiveContains(query) + || $0.badges.joined(separator: " ").localizedCaseInsensitiveContains(query) } } + private func normalizedSearchText(_ text: String) -> String { + text + .replacingOccurrences(of: "역", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + @ViewBuilder private func headerView() -> some View { Text("출발역 선택") @@ -196,14 +207,33 @@ extension TrainStationView { modalDismiss() } } label: { - HStack(spacing: 8) { - Text(row.stationName) - .pretendardCustomFont(textStyle: .titleRegular) - .foregroundStyle(.staticBlack) + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 8) { + Text(row.stationName) + .pretendardCustomFont(textStyle: .titleRegular) + .foregroundStyle(.staticBlack) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + + HStack(spacing: 8) { + ForEach(row.badges, id: \.self) { badge in + badgeView(badge) + } + } + .layoutPriority(1) + } - HStack(spacing: 8) { - ForEach(row.badges, id: \.self) { badge in - badgeView(badge) + VStack(alignment: .leading, spacing: 8) { + Text(row.stationName) + .pretendardCustomFont(textStyle: .titleRegular) + .foregroundStyle(.staticBlack) + .lineLimit(1) + + HStack(spacing: 8) { + ForEach(row.badges, id: \.self) { badge in + badgeView(badge) + } } } } @@ -214,6 +244,7 @@ extension TrainStationView { Text(distance) .pretendardCustomFont(textStyle: .caption) .foregroundStyle(.gray500) + .lineLimit(1) } } .buttonStyle(.plain) diff --git a/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift b/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift index 605ae3c..e751f6b 100644 --- a/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift +++ b/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift @@ -54,12 +54,15 @@ public struct TravelHistoryCardView: View { Text(item.departureName) .pretendardCustomFont(textStyle: .titleRegular) .foregroundStyle(.staticBlack) + .lineLimit(1) + .minimumScaleFactor(0.9) Text("출발역") .pretendardCustomFont(textStyle: .body2Medium) .foregroundStyle(.gray800) + .lineLimit(1) } - .frame(width: 52, alignment: .trailing) + .frame(width: 72, alignment: .trailing) } Spacer() diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Modal/CustomModalModifier.swift b/Projects/Shared/DesignSystem/Sources/Ui/Modal/CustomModalModifier.swift index 71126bb..8911f2c 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Modal/CustomModalModifier.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Modal/CustomModalModifier.swift @@ -64,6 +64,7 @@ public struct CustomModalModifier Date: Fri, 27 Mar 2026 02:36:07 +0900 Subject: [PATCH 24/27] =?UTF-8?q?refactor:=20=20api=20=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=A1=B8=EC=95=84=EC=9A=94=20=EC=97=AD=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20api=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=20=ED=99=88=EC=97=90=EC=84=9C=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20ui=20=EA=B5=AC=ED=98=84=20#11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../API/Sources/API/Station/StationAPI.swift | 3 - .../Station/DTO/FavoriteStationDTOModel.swift | 106 --------- .../Mapper/FavoriteStationDTOModel+.swift | 35 --- .../Station/StationRepositoryImpl.swift | 17 +- .../Sources/Station/StationRequest.swift | 16 -- .../Sources/Station/StationService.swift | 9 +- .../DefaultStationRepositoryImpl.swift | 36 +-- .../Sources/Station/StationInterface.swift | 7 +- .../Sources/Explore/ExploreCategory.swift | 31 +++ .../Station/FavoriteStationEntity.swift | 73 ------ .../Sources/Station/StationUseCaseImpl.swift | 14 +- .../Explore/Reducer/ExploreReducer.swift | 66 ++---- .../Sources/Explore/View/ExploreView.swift | 221 +++++++++++++----- .../Sources/Main/Reducer/HomeFeature.swift | 20 +- .../Reducer/TrainStationFeature.swift | 158 ++++--------- .../TrainStation/View/TrainStationView.swift | 6 +- .../Sources/Image/ImageAsset.swift | 13 ++ 17 files changed, 299 insertions(+), 532 deletions(-) delete mode 100644 Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift delete mode 100644 Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift create mode 100644 Projects/Domain/Entity/Sources/Explore/ExploreCategory.swift delete mode 100644 Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift diff --git a/Projects/Data/API/Sources/API/Station/StationAPI.swift b/Projects/Data/API/Sources/API/Station/StationAPI.swift index 5a3c5b8..b59a212 100644 --- a/Projects/Data/API/Sources/API/Station/StationAPI.swift +++ b/Projects/Data/API/Sources/API/Station/StationAPI.swift @@ -9,7 +9,6 @@ import Foundation public enum StationAPI { case allStation - case favoriteStation case addFavoriteStation case deleteFavoriteStation(deleteStationId: Int) @@ -17,8 +16,6 @@ public enum StationAPI { switch self { case .allStation: return "" - case .favoriteStation: - return "/favorites" case .addFavoriteStation: return "/favorites" case .deleteFavoriteStation(let deleteStationId): diff --git a/Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift b/Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift deleted file mode 100644 index 44b42de..0000000 --- a/Projects/Data/Model/Sources/Station/DTO/FavoriteStationDTOModel.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// FavoriteStationDTOModel.swift -// Model -// -// Created by Wonji Suh on 3/26/26. -// - -import Foundation - -public typealias FavoriteStationDTOModel = BaseResponseDTO - -public struct FavoriteStationPageResponseDTO: Decodable, Equatable { - public let content: [FavoriteStationItemResponseDTO] - public let totalElements: Int - public let totalPages: Int - public let size: Int - public let number: Int - public let first: Bool - public let last: Bool - - enum CodingKeys: String, CodingKey { - case content - case totalElements - case totalPages - case size - case number - case first - case last - case hasNext - } - - public init( - content: [FavoriteStationItemResponseDTO], - totalElements: Int, - totalPages: Int, - size: Int, - number: Int, - first: Bool, - last: Bool - ) { - self.content = content - self.totalElements = totalElements - self.totalPages = totalPages - self.size = size - self.number = number - self.first = first - self.last = last - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let content = try container.decode([FavoriteStationItemResponseDTO].self, forKey: .content) - let totalElements = try container.decode(Int.self, forKey: .totalElements) - let totalPages = try container.decode(Int.self, forKey: .totalPages) - let size = try container.decode(Int.self, forKey: .size) - let number = try container.decode(Int.self, forKey: .number) - - let hasNext = try container.decodeIfPresent(Bool.self, forKey: .hasNext) ?? false - let first = try container.decodeIfPresent(Bool.self, forKey: .first) ?? (number == 0) - let last = try container.decodeIfPresent(Bool.self, forKey: .last) ?? !hasNext - - self.init( - content: content, - totalElements: totalElements, - totalPages: totalPages, - size: size, - number: number, - first: first, - last: last - ) - } -} - -public struct FavoriteStationItemResponseDTO: Decodable, Equatable { - public let favoriteID: Int - public let stationID: Int - public let stationName: String - public let visitCount: Int - public let totalVisitMinutes: Int - public let createdAt: String - - enum CodingKeys: String, CodingKey { - case favoriteID = "favoriteId" - case stationID = "stationId" - case stationName - case visitCount - case totalVisitMinutes - case createdAt - } - - public init( - favoriteID: Int, - stationID: Int, - stationName: String, - visitCount: Int, - totalVisitMinutes: Int, - createdAt: String - ) { - self.favoriteID = favoriteID - self.stationID = stationID - self.stationName = stationName - self.visitCount = visitCount - self.totalVisitMinutes = totalVisitMinutes - self.createdAt = createdAt - } -} diff --git a/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift b/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift deleted file mode 100644 index ad665e5..0000000 --- a/Projects/Data/Model/Sources/Station/Mapper/FavoriteStationDTOModel+.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// FavoriteStationDTOModel+.swift -// Model -// -// Created by Wonji Suh on 3/26/26. -// - -import Entity - -public extension FavoriteStationPageResponseDTO { - func toDomain() -> FavoriteStationEntity { - FavoriteStationEntity( - items: content.map { $0.toDomain() }, - totalElements: totalElements, - totalPages: totalPages, - size: size, - page: number + 1, - isFirstPage: first, - isLastPage: last - ) - } -} - -public extension FavoriteStationItemResponseDTO { - func toDomain() -> FavoriteStationItemEntity { - FavoriteStationItemEntity( - favoriteID: favoriteID, - stationID: stationID, - stationName: stationName, - visitCount: visitCount, - totalVisitMinutes: totalVisitMinutes, - createdAt: createdAt - ) - } -} diff --git a/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift b/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift index 76d6dab..238f850 100644 --- a/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift @@ -42,19 +42,6 @@ public final class StationRepositoryImpl: StationInterface, @unchecked Sendable return dto.data.toDomain() } - public func fetchFavoriteStations( - page: Int, - size: Int - ) async throws -> FavoriteStationEntity { - let body: FavoriteStationRequest = .init( - page: page, - size: size, - sort: "stationName,ASC" - ) - let dto: FavoriteStationDTOModel = try await authorizedProvider.request(.favoriteStation(body: body)) - return dto.data.toDomain() - } - public func addFavoriteStation( stationID: Int ) async throws -> FavoriteStationMutationEntity { @@ -64,10 +51,10 @@ public final class StationRepositoryImpl: StationInterface, @unchecked Sendable } public func deleteFavoriteStation( - favoriteID: Int + stationID: Int ) async throws -> FavoriteStationMutationEntity { let dto: FavoriteStationMutationDTOModel = try await authorizedProvider.request( - .deleteFavoriteStation(deleteStationId: favoriteID) + .deleteFavoriteStation(deleteStationId: stationID) ) return dto.toDomain() } diff --git a/Projects/Data/Service/Sources/Station/StationRequest.swift b/Projects/Data/Service/Sources/Station/StationRequest.swift index f68e548..4aa62e6 100644 --- a/Projects/Data/Service/Sources/Station/StationRequest.swift +++ b/Projects/Data/Service/Sources/Station/StationRequest.swift @@ -29,22 +29,6 @@ public struct StationRequest: Encodable, Equatable { } } -public struct FavoriteStationRequest: Encodable, Equatable { - public let page: Int - public let size: Int - public let sort: String - - public init( - page: Int = 1, - size: Int = 10, - sort: String = "stationName,ASC" - ) { - self.page = max(page, 1) - self.size = max(size, 10) - self.sort = sort - } -} - public struct AddFavoriteStationRequest: Encodable, Equatable { public let stationID: Int diff --git a/Projects/Data/Service/Sources/Station/StationService.swift b/Projects/Data/Service/Sources/Station/StationService.swift index 81d3e36..2ff5bb2 100644 --- a/Projects/Data/Service/Sources/Station/StationService.swift +++ b/Projects/Data/Service/Sources/Station/StationService.swift @@ -14,7 +14,6 @@ import AsyncMoya public enum StationService { case allStation(body: StationRequest) - case favoriteStation(body: FavoriteStationRequest) case addFavoriteStation(body: AddFavoriteStationRequest) case deleteFavoriteStation(deleteStationId: Int) } @@ -31,8 +30,6 @@ extension StationService: BaseTargetType { switch self { case .allStation: return StationAPI.allStation.description - case .favoriteStation: - return StationAPI.favoriteStation.description case .addFavoriteStation: return StationAPI.addFavoriteStation.description case .deleteFavoriteStation(let deleteStationId): @@ -46,7 +43,7 @@ extension StationService: BaseTargetType { public var method: Moya.Method { switch self { - case .allStation, .favoriteStation: + case .allStation: return .get case .addFavoriteStation: return .post @@ -59,8 +56,6 @@ extension StationService: BaseTargetType { switch self { case .allStation(let body): return body.toDictionary - case .favoriteStation(let body): - return body.toDictionary case .addFavoriteStation(let body): return body.toDictionary case .deleteFavoriteStation: @@ -72,7 +67,7 @@ extension StationService: BaseTargetType { switch self { case .allStation: return APIHeader.notAccessTokenHeader - case .favoriteStation, .addFavoriteStation, .deleteFavoriteStation: + case .addFavoriteStation, .deleteFavoriteStation: return APIHeader.baseHeader } } diff --git a/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift index dda33dd..da52692 100644 --- a/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift @@ -49,40 +49,6 @@ final public class DefaultStationRepositoryImpl: StationInterface { ) } - public func fetchFavoriteStations( - page: Int, - size: Int - ) async throws -> FavoriteStationEntity { - let items: [FavoriteStationItemEntity] = [ - .init( - favoriteID: 1, - stationID: 10, - stationName: "서울역", - visitCount: 5, - totalVisitMinutes: 0, - createdAt: "2024-03-24T16:00:00" - ), - .init( - favoriteID: 2, - stationID: 20, - stationName: "강남역", - visitCount: 3, - totalVisitMinutes: 0, - createdAt: "2024-03-23T10:30:00" - ) - ] - - return FavoriteStationEntity( - items: items, - totalElements: items.count, - totalPages: 1, - size: size, - page: page, - isFirstPage: page == 1, - isLastPage: true - ) - } - public func addFavoriteStation( stationID: Int ) async throws -> FavoriteStationMutationEntity { @@ -93,7 +59,7 @@ final public class DefaultStationRepositoryImpl: StationInterface { } public func deleteFavoriteStation( - favoriteID: Int + stationID: Int ) async throws -> FavoriteStationMutationEntity { FavoriteStationMutationEntity( code: 200, diff --git a/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift b/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift index 0d73482..0fb1e57 100644 --- a/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift @@ -17,17 +17,12 @@ public protocol StationInterface: Sendable { size: Int ) async throws -> StationListEntity - func fetchFavoriteStations( - page: Int, - size: Int - ) async throws -> FavoriteStationEntity - func addFavoriteStation( stationID: Int ) async throws -> FavoriteStationMutationEntity func deleteFavoriteStation( - favoriteID: Int + stationID: Int ) async throws -> FavoriteStationMutationEntity } diff --git a/Projects/Domain/Entity/Sources/Explore/ExploreCategory.swift b/Projects/Domain/Entity/Sources/Explore/ExploreCategory.swift new file mode 100644 index 0000000..b683cb9 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Explore/ExploreCategory.swift @@ -0,0 +1,31 @@ +// +// ExploreCategory.swift +// Entity +// +// Created by wonji suh on 2026-03-27. +// + +import Foundation + +public enum ExploreCategory: String, CaseIterable, Equatable, Sendable { + case all + case cafe + case restaurant + case activity + case etc + + public var title: String { + switch self { + case .all: + return "전체" + case .cafe: + return "카페" + case .restaurant: + return "음식점" + case .activity: + return "액티비티" + case .etc: + return "기타" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift b/Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift deleted file mode 100644 index 93fa260..0000000 --- a/Projects/Domain/Entity/Sources/Station/FavoriteStationEntity.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// FavoriteStationEntity.swift -// Entity -// -// Created by Wonji Suh on 3/26/26. -// - -import Foundation - -public struct FavoriteStationEntity: Equatable, Hashable { - public let items: [FavoriteStationItemEntity] - public let totalElements: Int - public let totalPages: Int - public let size: Int - public let page: Int - public let isFirstPage: Bool - public let isLastPage: Bool - - public init( - items: [FavoriteStationItemEntity], - totalElements: Int, - totalPages: Int, - size: Int, - page: Int, - isFirstPage: Bool, - isLastPage: Bool - ) { - self.items = items - self.totalElements = totalElements - self.totalPages = totalPages - self.size = size - self.page = page - self.isFirstPage = isFirstPage - self.isLastPage = isLastPage - } -} - -public extension FavoriteStationEntity { - var hasNextPage: Bool { - !isLastPage && page < totalPages - } - - var nextPage: Int? { - hasNextPage ? page + 1 : nil - } -} - -public struct FavoriteStationItemEntity: Equatable, Hashable, Identifiable { - public let favoriteID: Int - public let stationID: Int - public let stationName: String - public let visitCount: Int - public let totalVisitMinutes: Int - public let createdAt: String - - public var id: Int { favoriteID } - - public init( - favoriteID: Int, - stationID: Int, - stationName: String, - visitCount: Int, - totalVisitMinutes: Int, - createdAt: String - ) { - self.favoriteID = favoriteID - self.stationID = stationID - self.stationName = stationName - self.visitCount = visitCount - self.totalVisitMinutes = totalVisitMinutes - self.createdAt = createdAt - } -} diff --git a/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift index 64ba65d..69661e6 100644 --- a/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift @@ -29,16 +29,6 @@ public struct StationUseCaseImpl: StationInterface { ) } - public func fetchFavoriteStations( - page: Int, - size: Int - ) async throws -> FavoriteStationEntity { - try await repository.fetchFavoriteStations( - page: page, - size: size - ) - } - public func addFavoriteStation( stationID: Int ) async throws -> FavoriteStationMutationEntity { @@ -46,9 +36,9 @@ public struct StationUseCaseImpl: StationInterface { } public func deleteFavoriteStation( - favoriteID: Int + stationID: Int ) async throws -> FavoriteStationMutationEntity { - try await repository.deleteFavoriteStation(favoriteID: favoriteID) + try await repository.deleteFavoriteStation(stationID: stationID) } } diff --git a/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift index cd3a2f1..0c920b9 100644 --- a/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift +++ b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift @@ -24,6 +24,7 @@ public struct ExploreReducer: Sendable { public var isLocationPermissionDenied: Bool = false public var locationError: String? @Presents public var alert: AlertState? + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty // 길찾기 관련 상태 public var selectedDestination: Destination? @@ -33,6 +34,7 @@ public struct ExploreReducer: Sendable { // 지도 카메라 제어 public var shouldReturnToCurrentLocation: Bool = false + public var selectedCategory: ExploreCategory = .all public init() {} } @@ -64,6 +66,7 @@ public struct ExploreReducer: Sendable { case retryLocationPermission case requestFullAccuracy case openSettings + case categoryTapped(ExploreCategory) // 길찾기 관련 액션 case searchRouteToGangnam case clearRoute @@ -135,7 +138,6 @@ extension ExploreReducer { ) -> Effect { switch action { case .onAppear: - // 앱이 나타날 때 위치 권한 상태 확인 - UI 블로킹 방지 return .run { send in let locationManager = await LocationPermissionManager.shared let currentStatus = await locationManager.authorizationStatus @@ -146,11 +148,11 @@ extension ExploreReducer { return .send(.async(.stopLocationUpdates)) case .requestLocationPermission: - return .send(.async(.requestLocationPermission)) + return .none case .retryLocationPermission: state.isLocationPermissionDenied = false - return .send(.async(.requestLocationPermission)) + return .none case .requestFullAccuracy: return .send(.async(.requestFullAccuracy)) @@ -166,6 +168,10 @@ extension ExploreReducer { } } + case .categoryTapped(let category): + state.selectedCategory = category + return .none + // 길찾기 관련 액션 case .searchRouteToGangnam: guard let currentLocation = state.currentLocation else { @@ -211,32 +217,11 @@ extension ExploreReducer { return .send(.async(.startLocationUpdates)) case .denied, .restricted: state.isLocationPermissionDenied = true - state.alert = AlertState { - TextState("위치 권한이 거부되었습니다") - } actions: { - ButtonState(action: Alert.openSettings) { - TextState("설정으로 이동") - } - ButtonState(role: .cancel, action: Alert.dismissAlert) { - TextState("나중에") - } - } message: { - TextState("위치 기반 서비스를 사용하려면 설정에서 위치 권한을 허용해주세요.") - } + state.alert = nil return .send(.async(.stopLocationUpdates)) case .notDetermined: - state.alert = AlertState { - TextState("위치 권한이 필요합니다") - } actions: { - ButtonState(action: Alert.confirmLocationPermission) { - TextState("허용") - } - ButtonState(role: .cancel, action: Alert.cancelLocationPermission) { - TextState("취소") - } - } message: { - TextState("TimeSpot이 근처 장소를 찾고 지도에 현재 위치를 표시하기 위해 위치 정보가 필요합니다.") - } + state.isLocationPermissionDenied = false + state.alert = nil return .none @unknown default: return .none @@ -282,29 +267,7 @@ extension ExploreReducer { ) -> Effect { switch action { case .requestLocationPermission: - return .run { send in - let locationManager = await LocationPermissionManager.shared - let status = await locationManager.requestLocationPermission() - - await send(.inner(.locationPermissionStatusChanged(status))) - - // 권한이 허용되면 현재 위치 가져오기 시작 - if status == .authorizedWhenInUse || status == .authorizedAlways { - await locationManager.startLocationUpdates() - - do { - if let location = try await locationManager.requestCurrentLocation() { - await send(.inner(.locationUpdated(location))) - } - } catch { - await send(.inner(.locationUpdateFailed(error.localizedDescription))) - } - } - - if let error = await locationManager.locationError { - await send(.inner(.locationUpdateFailed(error))) - } - } + return .none case .requestFullAccuracy: return .run { send in @@ -397,7 +360,7 @@ extension ExploreReducer { switch alertAction { case .confirmLocationPermission: state.alert = nil - return .send(.view(.requestLocationPermission)) + return .none case .cancelLocationPermission: state.alert = nil @@ -430,6 +393,7 @@ extension ExploreReducer.State: Hashable { hasher.combine(isLoadingRoute) hasher.combine(routeError) hasher.combine(shouldReturnToCurrentLocation) + hasher.combine(userSession) // Note: alert, selectedDestination, routeInfo are not hashed as they contain complex types } } diff --git a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift index 763be30..9b1215a 100644 --- a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift +++ b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift @@ -23,63 +23,16 @@ public struct ExploreView: View { public var body: some View { ZStack { - NaverMapComponent( - locationPermissionStatus: store.locationPermissionStatus, - currentLocation: store.currentLocation, - routeInfo: store.routeInfo, - destination: store.selectedDestination, - returnToLocation: store.shouldReturnToCurrentLocation - ) - .ignoresSafeArea(.all) + mapView() VStack(spacing: 0) { - HStack(spacing: 12) { - Button { - dismiss() - } label: { - Image(asset: .leftArrow) - .resizable() - .scaledToFit() - .frame(width: 56, height: 56) - .background(.staticWhite) - .clipShape(Circle()) - } - .buttonStyle(.plain) - - HStack { - Text("강릉역") - .pretendardCustomFont(textStyle: .titleRegular) - .foregroundStyle(.staticBlack) - - Spacer() - } - .padding(.horizontal, 24) - .frame(height: 56) - .background(.staticWhite) - .clipShape(RoundedRectangle(cornerRadius: 28)) - } - .padding(.top, 8) - .padding(.horizontal, 20) + headerSection() + .padding(.top, 8) + .padding(.horizontal, 20) Spacer() - HStack { - Spacer() - - Button { - store.send(.view(.returnToCurrentLocation)) - } label: { - Image(systemName: "location") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(.staticBlack) - .frame(width: 56, height: 56) - .background(.staticWhite) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.12), radius: 8, y: 2) - } - .padding(.trailing, 16) - .padding(.bottom, 36) - } + currentLocationButton() } } .onAppear { @@ -91,3 +44,167 @@ public struct ExploreView: View { .alert($store.scope(state: \.alert, action: \.scope.alert)) } } + +private extension ExploreView { + @ViewBuilder + func mapView() -> some View { + NaverMapComponent( + locationPermissionStatus: store.locationPermissionStatus, + currentLocation: store.currentLocation, + routeInfo: store.routeInfo, + destination: store.selectedDestination, + returnToLocation: store.shouldReturnToCurrentLocation + ) + .ignoresSafeArea(.all) + } + + @ViewBuilder + func headerSection() -> some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + backButton() + searchBar() + } + + categoryScrollView() + .padding(.top, 12) + } + } + + @ViewBuilder + func backButton() -> some View { + Button { + dismiss() + } label: { + Image(asset: .leftArrow) + .resizable() + .scaledToFit() + .frame(width: 56, height: 56) + .background(.staticWhite) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.08), radius: 12, y: 2) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func searchBar() -> some View { + HStack { + Text("\(store.userSession.travelStationName)역") + .pretendardFont(family: .Regular, size: 18) + .foregroundStyle(.staticBlack) + + Spacer() + } + .padding(.horizontal, 24) + .frame(height: 56) + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .shadow(color: .black.opacity(0.08), radius: 12, y: 2) + } + + @ViewBuilder + func categoryScrollView() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(ExploreCategory.allCases, id: \.self) { category in + categoryChip(category) + } + } + .padding(.horizontal, 2) + } + } + + @ViewBuilder + func categoryChip(_ category: ExploreCategory) -> some View { + let isSelected = store.selectedCategory == category + + Button { + store.send(.view(.categoryTapped(category))) + } label: { + HStack(spacing: 4) { + categoryIcon(for: category, isSelected: isSelected) + + Text(category.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(isSelected ? .staticBlack : .gray700) + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + .background(isSelected ? .orange200 : .staticWhite) + .overlay { + Capsule() + .stroke(isSelected ? .orange800 : .gray300, lineWidth: 1) + } + .clipShape(Capsule()) + .shadow(color: .black.opacity(isSelected ? 0.04 : 0.08), radius: 8, y: 2) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func currentLocationButton() -> some View { + HStack { + Spacer() + + Button { + store.send(.view(.returnToCurrentLocation)) + } label: { + Image(asset: .location) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .frame(width: 48, height: 48) + .background(.staticWhite, in: Circle()) + .shadow(color: .black.opacity(0.12), radius: 8, y: 2) + } + .padding(.trailing, 16) + .padding(.bottom, 36) + } + } + + @ViewBuilder + func categoryIcon( + for category: ExploreCategory, + isSelected: Bool + ) -> some View { + switch category { + case .all: + Image(asset: isSelected ? .tapAll : .all) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + case .cafe: + if isSelected { + Image(asset: .tapCaffee) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } else { + Image(systemName: "cup.and.saucer.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.gray600) + .frame(width: 16, height: 16) + } + case .restaurant: + Image(asset: isSelected ? .tapFood : .food) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + case .activity: + Image(asset: isSelected ? .tapGame : .game) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .foregroundStyle(isSelected ? .orange800 : .gray700) + + case .etc: + Image(asset: isSelected ? .tapEtc : .etc) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .foregroundStyle(isSelected ? .orange800 : .gray700) + + } + } +} diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index 5c212d0..01fbac2 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -40,6 +40,8 @@ public struct HomeFeature { var isSelected: Bool = false var isDepartureTimeSet: Bool = false var selectedStation: Station = .seoul + var selectedStationID: Int? + var selectedStationName: String = "" var hasSelectedStation: Bool = false var customAlertMode: CustomAlertMode? = nil var hasAppearedOnce: Bool = false @@ -178,19 +180,21 @@ extension HomeFeature { action: PresentationAction ) -> Effect { switch action { - case .presented(.delegate(.stationSelected(let station))): + case .presented(.delegate(.stationSelected(let row))): + guard let station = row.station else { return .none } state.selectedStation = station + state.selectedStationID = row.stationID + state.selectedStationName = row.stationName state.isSelected = false state.hasSelectedStation = true state.trainStation = nil state.$userSession.withLock { - $0.travelID = station.id - $0.travelStationName = station.displayName + $0.travelID = String(row.stationID) + $0.travelStationName = row.stationName } return .merge( .cancel(id: TrainStationFeature.CancelID.checkAccessToken), .cancel(id: TrainStationFeature.CancelID.fetchStations), - .cancel(id: TrainStationFeature.CancelID.fetchFavoriteStations), .cancel(id: TrainStationFeature.CancelID.favoriteMutation), state.shouldShowDepartureWarningToast ? .send(.inner(.showDepartureWarningToast)) : .none ) @@ -200,7 +204,6 @@ extension HomeFeature { return .merge( .cancel(id: TrainStationFeature.CancelID.checkAccessToken), .cancel(id: TrainStationFeature.CancelID.fetchStations), - .cancel(id: TrainStationFeature.CancelID.fetchFavoriteStations), .cancel(id: TrainStationFeature.CancelID.favoriteMutation) ) @@ -225,7 +228,10 @@ extension HomeFeature { case .selectStationButtonTapped: state.isSelected = true - state.trainStation = .init(selectedStation: state.selectedStation) + state.trainStation = .init( + selectedStation: state.selectedStation, + selectedStationID: state.selectedStationID + ) return .none case .departureTimeButtonTapped: @@ -362,6 +368,8 @@ extension HomeFeature { state.isSelected = false state.isDepartureTimeSet = false state.selectedStation = .seoul + state.selectedStationID = nil + state.selectedStationName = "" state.hasSelectedStation = false state.$userSession.withLock { $0.travelID = "" diff --git a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift index 025bef9..7d2c49d 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift @@ -25,7 +25,6 @@ public struct TrainStationFeature { public enum CancelID: Hashable { case checkAccessToken case fetchStations - case fetchFavoriteStations case favoriteMutation } @@ -34,15 +33,19 @@ public struct TrainStationFeature { var searchText: String = "" var shouldShowFavoriteSection: Bool = false var selectedStation: Station - var favoriteItems: [FavoriteStationItemEntity] = [] + var selectedStationID: Int? var favoriteRows: [StationRowModel] = [] var nearbyRows: [StationRowModel] = [] var majorRows: [StationRowModel] = [] var isLoading: Bool = false var errorMessage: String? - public init(selectedStation: Station = .seoul) { + public init( + selectedStation: Station = .seoul, + selectedStationID: Int? = nil + ) { self.selectedStation = selectedStation + self.selectedStationID = selectedStationID } } @@ -60,7 +63,7 @@ public struct TrainStationFeature { @CasePathable public enum View { case onAppear - case stationTapped(Station) + case stationTapped(StationRowModel) case favoriteButtonTapped(StationRowModel) } @@ -70,7 +73,6 @@ public struct TrainStationFeature { public enum AsyncAction: Equatable { case checkAccessToken case fetchStations - case fetchFavoriteStations case addFavoriteStation(Int) case deleteFavoriteStation(Int) } @@ -80,8 +82,6 @@ public struct TrainStationFeature { case accessTokenChecked(Bool) case fetchStationsResponse(StationListEntity) case fetchStationsFailed(String) - case fetchFavoriteStationsResponse(FavoriteStationEntity) - case fetchFavoriteStationsFailed(String) case addFavoriteStationResponse case addFavoriteStationFailed(String) case deleteFavoriteStationResponse @@ -90,7 +90,7 @@ public struct TrainStationFeature { //MARK: - DelegateAction public enum DelegateAction: Equatable { - case stationSelected(Station) + case stationSelected(StationRowModel) } @@ -130,21 +130,15 @@ extension TrainStationFeature { .send(.async(.fetchStations)) ) - case .stationTapped(let station): + case .stationTapped(let row): + guard let station = row.station else { return .none } state.selectedStation = station - return .send(.delegate(.stationSelected(station))) + state.selectedStationID = row.stationID + return .send(.delegate(.stationSelected(row))) case .favoriteButtonTapped(let row): guard state.shouldShowFavoriteSection else { return .none } if row.isFavorite { - let resolvedFavoriteID = row.favoriteID ?? state.favoriteItems.first(where: { - normalizedStationName($0.stationName) == normalizedStationName(row.stationName) - })?.favoriteID - - guard let favoriteID = resolvedFavoriteID else { - return .none - } - - return .send(.async(.deleteFavoriteStation(favoriteID))) + return .send(.async(.deleteFavoriteStation(row.stationID))) } else { return .send(.async(.addFavoriteStation(row.stationID))) } @@ -183,19 +177,6 @@ extension TrainStationFeature { } } .cancellable(id: CancelID.fetchStations) - case .fetchFavoriteStations: - return .run { [stationUseCase] send in - do { - let entity = try await stationUseCase.fetchFavoriteStations( - page: 1, - size: 30 - ) - await send(.inner(.fetchFavoriteStationsResponse(entity))) - } catch { - await send(.inner(.fetchFavoriteStationsFailed(error.localizedDescription))) - } - } - .cancellable(id: CancelID.fetchFavoriteStations) case .addFavoriteStation(let stationID): return .run { [stationUseCase] send in do { @@ -206,10 +187,10 @@ extension TrainStationFeature { } } .cancellable(id: CancelID.favoriteMutation, cancelInFlight: true) - case .deleteFavoriteStation(let favoriteID): + case .deleteFavoriteStation(let stationID): return .run { [stationUseCase] send in do { - _ = try await stationUseCase.deleteFavoriteStation(favoriteID: favoriteID) + _ = try await stationUseCase.deleteFavoriteStation(stationID: stationID) await send(.inner(.deleteFavoriteStationResponse)) } catch { await send(.inner(.deleteFavoriteStationFailed(error.localizedDescription))) @@ -236,14 +217,11 @@ extension TrainStationFeature { switch action { case .accessTokenChecked(let shouldShowFavoriteSection): state.shouldShowFavoriteSection = shouldShowFavoriteSection - guard shouldShowFavoriteSection else { return .none } - return .send(.async(.fetchFavoriteStations)) + return .none case .fetchStationsResponse(let entity): + state.favoriteRows = makeFavoriteRows(entity.favoriteStations) state.nearbyRows = makeNearbyRows(entity.nearbyStations) state.majorRows = makeMajorRows(entity.stations.content) - if state.shouldShowFavoriteSection { - state.favoriteRows = makeFavoriteSummaryRows(entity.favoriteStations) - } applyFavoriteState(state: &state) state.isLoading = false return .none @@ -251,29 +229,13 @@ extension TrainStationFeature { state.errorMessage = message state.isLoading = false return .none - case .fetchFavoriteStationsResponse(let entity): - state.favoriteItems = entity.items - applyFavoriteState(state: &state) - return .none - case .fetchFavoriteStationsFailed(let message): - // favorites API가 실패해도 allStation.favoriteStations로 이미 렌더 가능하면 화면은 유지 - if state.favoriteRows.isEmpty && state.favoriteItems.isEmpty { - state.errorMessage = message - } - return .none case .addFavoriteStationResponse: - return .merge( - .send(.async(.fetchFavoriteStations)), - .send(.async(.fetchStations)) - ) + return .send(.async(.fetchStations)) case .addFavoriteStationFailed(let message): state.errorMessage = message return .none case .deleteFavoriteStationResponse: - return .merge( - .send(.async(.fetchFavoriteStations)), - .send(.async(.fetchStations)) - ) + return .send(.async(.fetchStations)) case .deleteFavoriteStationFailed(let message): state.errorMessage = message return .none @@ -284,100 +246,72 @@ extension TrainStationFeature { extension TrainStationFeature.State: Hashable {} private extension TrainStationFeature { - func makeNearbyRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { - Array(stations.sorted { normalizedStationName($0.name) < normalizedStationName($1.name) }.prefix(3)).map { station in + func makeFavoriteRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { + Array( + Dictionary( + stations.map { station in + (normalizedStationName(station.name), station) + }, + uniquingKeysWith: { first, _ in first } + ).values + ) + .sorted { normalizedStationName($0.name) < normalizedStationName($1.name) } + .map { station in let normalizedName = normalizedStationName(station.name) return StationRowModel( - id: "nearby-\(station.stationID)", - favoriteID: nil, + id: "favorite-\(station.stationID)", + favoriteID: station.stationID, station: Station(displayName: normalizedName), stationID: station.stationID, stationName: normalizedName, badges: station.lines, - distanceText: "2.3km", - isFavorite: false + distanceText: nil, + isFavorite: true ) } } - func makeMajorRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { - stations - .sorted { normalizedStationName($0.name) < normalizedStationName($1.name) } - .map { station in + func makeNearbyRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { + Array(stations.sorted { normalizedStationName($0.name) < normalizedStationName($1.name) }.prefix(3)).map { station in let normalizedName = normalizedStationName(station.name) return StationRowModel( - id: "station-\(station.stationID)", + id: "nearby-\(station.stationID)", favoriteID: nil, station: Station(displayName: normalizedName), stationID: station.stationID, stationName: normalizedName, badges: station.lines, - distanceText: nil, + distanceText: "2.3km", isFavorite: false ) } } - func makeFavoriteRows( - _ favorites: [FavoriteStationItemEntity], - stationRows: [StationRowModel] - ) -> [StationRowModel] { - favorites - .sorted { normalizedStationName($0.stationName) < normalizedStationName($1.stationName) } - .map { favorite in - let normalizedName = normalizedStationName(favorite.stationName) - let matchedLines = stationRows.first(where: { - normalizedStationName($0.stationName) == normalizedName - })?.badges ?? [] - - return StationRowModel( - id: "favorite-\(favorite.favoriteID)", - favoriteID: favorite.favoriteID, - station: Station(displayName: normalizedName), - stationID: favorite.stationID, - stationName: normalizedName, - badges: matchedLines, - distanceText: nil, - isFavorite: true - ) - } - } - - func makeFavoriteSummaryRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { - Array( - Dictionary( - stations.map { station in - (normalizedStationName(station.name), station) - }, - uniquingKeysWith: { first, _ in first } - ).values - ) - .sorted { $0.name < $1.name } - .map { station in + func makeMajorRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { + stations + .sorted { normalizedStationName($0.name) < normalizedStationName($1.name) } + .map { station in let normalizedName = normalizedStationName(station.name) return StationRowModel( - id: "favorite-summary-\(station.stationID)", + id: "station-\(station.stationID)", favoriteID: nil, station: Station(displayName: normalizedName), stationID: station.stationID, stationName: normalizedName, badges: station.lines, distanceText: nil, - isFavorite: true + isFavorite: false ) } } func applyFavoriteState(state: inout State) { let favoriteNameMap = Dictionary( - uniqueKeysWithValues: state.favoriteItems.map { - (normalizedStationName($0.stationName), $0.favoriteID) + uniqueKeysWithValues: state.favoriteRows.map { + (normalizedStationName($0.stationName), $0.stationID) } ) - let stationRows = state.majorRows + state.nearbyRows - state.favoriteRows = makeFavoriteRows(state.favoriteItems, stationRows: stationRows) - state.nearbyRows = state.nearbyRows.map { row in let favoriteID = favoriteNameMap[normalizedStationName(row.stationName)] return StationRowModel( diff --git a/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift b/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift index aecad03..2fbb876 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift @@ -202,8 +202,8 @@ extension TrainStationView { private func stationRowView(_ row: StationRowModel) -> some View { HStack(spacing: 12) { Button { - if let station = row.station { - store.send(.view(.stationTapped(station))) + if row.station != nil { + store.send(.view(.stationTapped(row))) modalDismiss() } } label: { @@ -254,7 +254,7 @@ extension TrainStationView { } label: { Image(systemName: row.isFavorite ? "star.fill" : "star") .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(row.isFavorite ? .orange700 : .gray550) + .foregroundStyle(row.isFavorite ? .orange800 : .gray550) .frame(width: 20, height: 20) } .buttonStyle(.plain) diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 61ad505..acaca08 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -19,6 +19,19 @@ public enum ImageAsset: String { case arrowRight case leftArrow case lineHeight + case all + case game + case shopping + case etc + case food + case tapAll + case tapCaffee + case tapGame + case tapShopping + case tapEtc + case tapFood + case location + // MARK: - 지도 From 55c6954f1d89ef951d83912e786886f14e4d1642 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 27 Mar 2026 02:54:06 +0900 Subject: [PATCH 25/27] =?UTF-8?q?=F0=9F=9A=80=20fix:=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EA=B8=B0=EB=B0=98=20=EC=A3=BC=EC=9A=94?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=EC=82=AC=ED=95=AD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Critical 이슈 수정: - AuthInterceptor: 빈 토큰 하드코딩 → 실제 토큰 사용 (48-49번 라인) - 모든 API 호출 실패 원인 해결 🎨 UI 코드 품질 개선: - HomeView: 매직 넘버를 LayoutConstants enum으로 분리 - 하드코딩된 값들 (524, 176, 492 등) → 상수화 - 유지보수성 및 일관성 향상 🌍 국제화 준비: - 문자열 상수를 Strings enum으로 분리 - "현재 시간", "출발 시간", "HOURS", "MINUTES" 등 상수화 - 향후 다국어 지원 기반 마련 🔒 보안 개선: - 디버그 로그에서 민감한 토큰 정보 조건부 출력 - 프로덕션 빌드에서 토큰 값 로깅 방지 --- .../Auth/Interceptor/AuthInterceptor.swift | 8 ++- .../Sources/Explore/View/ExploreView.swift | 45 +++++++++++-- .../Home/Sources/Main/View/HomeView.swift | 63 ++++++++++++------- 3 files changed, 87 insertions(+), 29 deletions(-) diff --git a/Projects/Data/Repository/Sources/OAuth/Auth/Interceptor/AuthInterceptor.swift b/Projects/Data/Repository/Sources/OAuth/Auth/Interceptor/AuthInterceptor.swift index 8f0b5d0..91092ac 100644 --- a/Projects/Data/Repository/Sources/OAuth/Auth/Interceptor/AuthInterceptor.swift +++ b/Projects/Data/Repository/Sources/OAuth/Auth/Interceptor/AuthInterceptor.swift @@ -38,15 +38,19 @@ actor TokenRefreshManager { do { let tokens = try await authRepository.refresh() + #if DEBUG #logDebug("✅ Token refresh completed successfully: \(tokens)") + #else + #logDebug("✅ Token refresh completed successfully") + #endif // 키체인에 새 토큰 저장 try await keychainManager.save(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken) // AuthSessionManager에 새 credential 업데이트 let newCredential = AccessTokenCredential.make( - accessToken: "", - refreshToken: "" + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken ) // 메인 스레드에서 세션 매니저 업데이트 diff --git a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift index 9b1215a..87765c3 100644 --- a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift +++ b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift @@ -105,22 +105,37 @@ private extension ExploreView { @ViewBuilder func categoryScrollView() -> some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(ExploreCategory.allCases, id: \.self) { category in - categoryChip(category) + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(ExploreCategory.allCases, id: \.self) { category in + categoryChip(category) { + scrollToCategory(category, with: proxy) + } + .id(category) + } } + .padding(.horizontal, 2) + } + .onAppear { + scrollToCategory(store.selectedCategory, with: proxy, animated: false) + } + .onChange(of: store.selectedCategory) { _, category in + scrollToCategory(category, with: proxy) } - .padding(.horizontal, 2) } } @ViewBuilder - func categoryChip(_ category: ExploreCategory) -> some View { + func categoryChip( + _ category: ExploreCategory, + onTap: @escaping () -> Void + ) -> some View { let isSelected = store.selectedCategory == category Button { store.send(.view(.categoryTapped(category))) + onTap() } label: { HStack(spacing: 4) { categoryIcon(for: category, isSelected: isSelected) @@ -163,6 +178,24 @@ private extension ExploreView { } } + func scrollToCategory( + _ category: ExploreCategory, + with proxy: ScrollViewProxy, + animated: Bool = true + ) { + let action = { + proxy.scrollTo(category == .all ? ExploreCategory.all : category, anchor: .leading) + } + + if animated { + withAnimation(.easeInOut(duration: 0.2)) { + action() + } + } else { + action() + } + } + @ViewBuilder func categoryIcon( for category: ExploreCategory, diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 82649a1..92cf7f5 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -15,6 +15,29 @@ import ComposableArchitecture public struct HomeView: View { @Bindable var store: StoreOf + // MARK: - Layout Constants + private enum LayoutConstants { + static let heroHeight: CGFloat = 524 + static let pickerWidth: CGFloat = 176 + static let pickerHeight: CGFloat = 180 + static let pickerOffset = UIOffset(horizontal: -184, vertical: 492) + static let timeCapsuleHeight: CGFloat = 77 + static let cornerRadius: CGFloat = 28 + static let heroCornerRadius: CGFloat = 40 + static let timeLeftCornerRadius: CGFloat = 36 + } + + // MARK: - String Constants (준비: 향후 국제화용) + private enum Strings { + static let currentTime = "현재 시간" + static let departureTime = "출발 시간" + static let hours = "HOURS" + static let minutes = "MINUTES" + static let exploreNearby = "주변 탐색 시작하기" + static let departureTimeSelection = "출발 시간 선택" + static let insufficientWaitTime = "대기 시간이 부족합니다 (최소 20분 필요)" + } + public init(store: StoreOf) { self.store = store } @@ -56,7 +79,7 @@ public struct HomeView: View { } .onChange(of: store.shouldShowDepartureWarningToast) { _, shouldShow in guard shouldShow else { return } - ToastManager.shared.showWarning("대기 시간이 부족합니다 (최소 20분 필요)") + ToastManager.shared.showWarning(Strings.insufficientWaitTime) } } } @@ -67,15 +90,13 @@ extension HomeView { @ViewBuilder fileprivate func logoContentView() -> some View { - let heroHeight: CGFloat = 524 - GeometryReader { geometry in ZStack(alignment: .topLeading) { ZStack(alignment: .top) { Image(asset: .homeLogo) .resizable() .scaledToFill() - .frame(width: geometry.size.width, height: heroHeight, alignment: .top) + .frame(width: geometry.size.width, height: LayoutConstants.heroHeight, alignment: .top) .scaleEffect(1.06, anchor: .top) .ignoresSafeArea(edges: .top) @@ -94,25 +115,25 @@ extension HomeView { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .padding(.bottom, 28) } - .frame(width: geometry.size.width, height: heroHeight) + .frame(width: geometry.size.width, height: LayoutConstants.heroHeight) .background(.clear) .clipShape( UnevenRoundedRectangle( cornerRadii: .init( - bottomLeading: 40, - bottomTrailing: 40 + bottomLeading: LayoutConstants.heroCornerRadius, + bottomTrailing: LayoutConstants.heroCornerRadius ) ) ) if store.departureTimePickerVisible { departureTimePickerView() - .offset(x: geometry.size.width - 8 - 176 - 8, y: 492) + .offset(x: geometry.size.width + LayoutConstants.pickerOffset.horizontal, y: LayoutConstants.pickerOffset.vertical) .zIndex(2) } } } - .frame(height: heroHeight) + .frame(height: LayoutConstants.heroHeight) } @ViewBuilder @@ -161,7 +182,7 @@ extension HomeView { fileprivate func selectTrainTimeView() -> some View { HStack(alignment: .top, spacing: 10) { timeCapsuleView( - title: "현재 시간", + title: Strings.currentTime, time: store.currentTime.formattedKoreanTime(), timeColor: .gray900, backgroundColor: .gray100 @@ -171,7 +192,7 @@ extension HomeView { store.send(.view(.departureTimeButtonTapped)) } label: { timeCapsuleView( - title: "출발 시간", + title: Strings.departureTime, time: store.isDepartureTimeSet ? store.departureTime.formattedKoreanTime() : store.currentTime.formattedKoreanTime(), @@ -189,7 +210,7 @@ extension HomeView { @ViewBuilder fileprivate func departureTimePickerView() -> some View { DatePicker( - "출발 시간 선택", + Strings.departureTimeSelection, selection: $store.departureTime, in: store.currentTime..., displayedComponents: [.hourAndMinute] @@ -200,11 +221,11 @@ extension HomeView { .onChange(of: store.departureTime) { _, newValue in store.send(.view(.departureTimeChanged(newValue))) } - .frame(height: 180) + .frame(height: LayoutConstants.pickerHeight) .clipped() - .frame(width: 176) + .frame(width: LayoutConstants.pickerWidth) .background(.gray300) - .clipShape(RoundedRectangle(cornerRadius: 24)) + .clipShape(RoundedRectangle(cornerRadius: LayoutConstants.cornerRadius)) } @ViewBuilder @@ -224,9 +245,9 @@ extension HomeView { .foregroundStyle(timeColor) } .frame(maxWidth: .infinity) - .frame(height: 77) + .frame(height: LayoutConstants.timeCapsuleHeight) .background(backgroundColor) - .cornerRadius(28) + .cornerRadius(LayoutConstants.cornerRadius) } @@ -241,7 +262,7 @@ extension HomeView { .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) .frame(height: 69) - Text("HOURS") + Text(Strings.hours) .pretendardCustomFont(textStyle: .caption) .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) @@ -264,7 +285,7 @@ extension HomeView { .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) .frame(height: 69) - Text("MINUTES") + Text(Strings.minutes) .pretendardCustomFont(textStyle: .caption) .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) @@ -275,7 +296,7 @@ extension HomeView { } .padding(.vertical, 21) .background( - RoundedRectangle(cornerRadius: 36) + RoundedRectangle(cornerRadius: LayoutConstants.timeLeftCornerRadius) .fill(.white) ) .padding(.horizontal, 24) @@ -287,7 +308,7 @@ extension HomeView { action: { store.send(.view(.exploreNearbyButtonTapped)) }, - title: "주변 탐색 시작하기", + title: Strings.exploreNearby, config: CustomButtonConfig.create(), isEnable: store.isExploreNearbyEnabled ) From 6bcfbf7b6d89f23251b8b1993fcdb0e4dfcdfdb9 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 27 Mar 2026 02:55:27 +0900 Subject: [PATCH 26/27] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20refactor:=20TCA?= =?UTF-8?q?=20=ED=8C=A8=ED=84=B4=EC=97=90=20=EB=A7=9E=EB=8A=94=20=EC=83=81?= =?UTF-8?q?=EC=88=98=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 📦 상수 재구성: - 문자열 상수를 HomeFeature.Strings로 이동 - TCA Feature 레벨에서 비즈니스 문자열 관리 - View 레벨에서는 Layout 상수만 관리 🎨 Layout 상수 구조화: - Layout.Hero: 히어로 섹션 관련 상수 - Layout.TimePicker: 시간 선택기 관련 상수 - Layout.TimeCapsule: 시간 캡슐 관련 상수 - Layout.TimeDisplay: 시간 표시 관련 상수 ✨ 개선 효과: - 의미 있는 네이밍으로 가독성 향상 - TCA 아키텍처 패턴 준수 - 유지보수성 및 확장성 향상 --- .../Sources/Main/Reducer/HomeFeature.swift | 11 +++ .../Home/Sources/Main/View/HomeView.swift | 78 ++++++++++--------- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index 01fbac2..49d0b7b 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -22,6 +22,17 @@ public struct HomeFeature { public init() {} + // MARK: - Constants + public enum Strings { + public static let currentTime = "현재 시간" + public static let departureTime = "출발 시간" + public static let hours = "HOURS" + public static let minutes = "MINUTES" + public static let exploreNearby = "주변 탐색 시작하기" + public static let departureTimeSelection = "출발 시간 선택" + public static let insufficientWaitTime = "대기 시간이 부족합니다 (최소 20분 필요)" + } + @ObservableState public struct State: Equatable { public init() { diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 92cf7f5..704f350 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -16,28 +16,30 @@ public struct HomeView: View { @Bindable var store: StoreOf // MARK: - Layout Constants - private enum LayoutConstants { - static let heroHeight: CGFloat = 524 - static let pickerWidth: CGFloat = 176 - static let pickerHeight: CGFloat = 180 - static let pickerOffset = UIOffset(horizontal: -184, vertical: 492) - static let timeCapsuleHeight: CGFloat = 77 - static let cornerRadius: CGFloat = 28 - static let heroCornerRadius: CGFloat = 40 - static let timeLeftCornerRadius: CGFloat = 36 - } + private enum Layout { + enum Hero { + static let height: CGFloat = 524 + static let cornerRadius: CGFloat = 40 + } + + enum TimePicker { + static let width: CGFloat = 176 + static let height: CGFloat = 180 + static let offset = UIOffset(horizontal: -184, vertical: 492) + static let cornerRadius: CGFloat = 28 + } - // MARK: - String Constants (준비: 향후 국제화용) - private enum Strings { - static let currentTime = "현재 시간" - static let departureTime = "출발 시간" - static let hours = "HOURS" - static let minutes = "MINUTES" - static let exploreNearby = "주변 탐색 시작하기" - static let departureTimeSelection = "출발 시간 선택" - static let insufficientWaitTime = "대기 시간이 부족합니다 (최소 20분 필요)" + enum TimeCapsule { + static let height: CGFloat = 77 + static let cornerRadius: CGFloat = 28 + } + + enum TimeDisplay { + static let cornerRadius: CGFloat = 36 + } } + public init(store: StoreOf) { self.store = store } @@ -79,7 +81,7 @@ public struct HomeView: View { } .onChange(of: store.shouldShowDepartureWarningToast) { _, shouldShow in guard shouldShow else { return } - ToastManager.shared.showWarning(Strings.insufficientWaitTime) + ToastManager.shared.showWarning(HomeFeature.Strings.insufficientWaitTime) } } } @@ -96,7 +98,7 @@ extension HomeView { Image(asset: .homeLogo) .resizable() .scaledToFill() - .frame(width: geometry.size.width, height: LayoutConstants.heroHeight, alignment: .top) + .frame(width: geometry.size.width, height: Layout.Hero.height, alignment: .top) .scaleEffect(1.06, anchor: .top) .ignoresSafeArea(edges: .top) @@ -115,25 +117,25 @@ extension HomeView { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .padding(.bottom, 28) } - .frame(width: geometry.size.width, height: LayoutConstants.heroHeight) + .frame(width: geometry.size.width, height: Layout.Hero.height) .background(.clear) .clipShape( UnevenRoundedRectangle( cornerRadii: .init( - bottomLeading: LayoutConstants.heroCornerRadius, - bottomTrailing: LayoutConstants.heroCornerRadius + bottomLeading: Layout.Hero.cornerRadius, + bottomTrailing: Layout.Hero.cornerRadius ) ) ) if store.departureTimePickerVisible { departureTimePickerView() - .offset(x: geometry.size.width + LayoutConstants.pickerOffset.horizontal, y: LayoutConstants.pickerOffset.vertical) + .offset(x: geometry.size.width + Layout.TimePicker.offset.horizontal, y: Layout.TimePicker.offset.vertical) .zIndex(2) } } } - .frame(height: LayoutConstants.heroHeight) + .frame(height: Layout.Hero.height) } @ViewBuilder @@ -182,7 +184,7 @@ extension HomeView { fileprivate func selectTrainTimeView() -> some View { HStack(alignment: .top, spacing: 10) { timeCapsuleView( - title: Strings.currentTime, + title: HomeFeature.Strings.currentTime, time: store.currentTime.formattedKoreanTime(), timeColor: .gray900, backgroundColor: .gray100 @@ -192,7 +194,7 @@ extension HomeView { store.send(.view(.departureTimeButtonTapped)) } label: { timeCapsuleView( - title: Strings.departureTime, + title: HomeFeature.Strings.departureTime, time: store.isDepartureTimeSet ? store.departureTime.formattedKoreanTime() : store.currentTime.formattedKoreanTime(), @@ -210,7 +212,7 @@ extension HomeView { @ViewBuilder fileprivate func departureTimePickerView() -> some View { DatePicker( - Strings.departureTimeSelection, + HomeFeature.Strings.departureTimeSelection, selection: $store.departureTime, in: store.currentTime..., displayedComponents: [.hourAndMinute] @@ -221,11 +223,11 @@ extension HomeView { .onChange(of: store.departureTime) { _, newValue in store.send(.view(.departureTimeChanged(newValue))) } - .frame(height: LayoutConstants.pickerHeight) + .frame(height: Layout.TimePicker.height) .clipped() - .frame(width: LayoutConstants.pickerWidth) + .frame(width: Layout.TimePicker.width) .background(.gray300) - .clipShape(RoundedRectangle(cornerRadius: LayoutConstants.cornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: Layout.TimePicker.cornerRadius)) } @ViewBuilder @@ -245,9 +247,9 @@ extension HomeView { .foregroundStyle(timeColor) } .frame(maxWidth: .infinity) - .frame(height: LayoutConstants.timeCapsuleHeight) + .frame(height: Layout.TimeCapsule.height) .background(backgroundColor) - .cornerRadius(LayoutConstants.cornerRadius) + .cornerRadius(Layout.TimeCapsule.cornerRadius) } @@ -262,7 +264,7 @@ extension HomeView { .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) .frame(height: 69) - Text(Strings.hours) + Text(HomeFeature.Strings.hours) .pretendardCustomFont(textStyle: .caption) .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) @@ -285,7 +287,7 @@ extension HomeView { .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) .frame(height: 69) - Text(Strings.minutes) + Text(HomeFeature.Strings.minutes) .pretendardCustomFont(textStyle: .caption) .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) @@ -296,7 +298,7 @@ extension HomeView { } .padding(.vertical, 21) .background( - RoundedRectangle(cornerRadius: LayoutConstants.timeLeftCornerRadius) + RoundedRectangle(cornerRadius: Layout.TimeDisplay.cornerRadius) .fill(.white) ) .padding(.horizontal, 24) @@ -308,7 +310,7 @@ extension HomeView { action: { store.send(.view(.exploreNearbyButtonTapped)) }, - title: Strings.exploreNearby, + title: HomeFeature.Strings.exploreNearby, config: CustomButtonConfig.create(), isEnable: store.isExploreNearbyEnabled ) From 64217a850b7eb199a9db456c70329cabcf993a7f Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 27 Mar 2026 03:15:33 +0900 Subject: [PATCH 27/27] =?UTF-8?q?fix:=20ExploreView=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Explore/Reducer/ExploreReducer.swift | 6 ++ .../Sources/Explore/View/ExploreView.swift | 65 ++++++++++++------- .../Sources/Image/ImageAsset.swift | 3 +- 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift index 0c920b9..1ceafef 100644 --- a/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift +++ b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift @@ -23,6 +23,7 @@ public struct ExploreReducer: Sendable { public var currentLocation: CLLocation? public var isLocationPermissionDenied: Bool = false public var locationError: String? + public var searchText: String = "" @Presents public var alert: AlertState? @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty @@ -66,6 +67,7 @@ public struct ExploreReducer: Sendable { case retryLocationPermission case requestFullAccuracy case openSettings + case searchTextChanged(String) case categoryTapped(ExploreCategory) // 길찾기 관련 액션 case searchRouteToGangnam @@ -168,6 +170,10 @@ extension ExploreReducer { } } + case .searchTextChanged(let text): + state.searchText = text + return .none + case .categoryTapped(let category): state.selectedCategory = category return .none diff --git a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift index 87765c3..8d37d3d 100644 --- a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift +++ b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift @@ -89,12 +89,30 @@ private extension ExploreView { @ViewBuilder func searchBar() -> some View { - HStack { - Text("\(store.userSession.travelStationName)역") + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.gray600) + + ZStack(alignment: .leading) { + if store.searchText.isEmpty { + Text("\(store.userSession.travelStationName)역") + .pretendardFont(family: .Regular, size: 18) + .foregroundStyle(.gray600) + } + + TextField( + "", + text: Binding( + get: { store.searchText }, + set: { store.send(.view(.searchTextChanged($0))) } + ) + ) .pretendardFont(family: .Regular, size: 18) .foregroundStyle(.staticBlack) - - Spacer() + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } } .padding(.horizontal, 24) .frame(height: 56) @@ -109,9 +127,7 @@ private extension ExploreView { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(ExploreCategory.allCases, id: \.self) { category in - categoryChip(category) { - scrollToCategory(category, with: proxy) - } + categoryChip(category) .id(category) } } @@ -121,21 +137,19 @@ private extension ExploreView { scrollToCategory(store.selectedCategory, with: proxy, animated: false) } .onChange(of: store.selectedCategory) { _, category in - scrollToCategory(category, with: proxy) + DispatchQueue.main.async { + scrollToCategory(category, with: proxy) + } } } } @ViewBuilder - func categoryChip( - _ category: ExploreCategory, - onTap: @escaping () -> Void - ) -> some View { + func categoryChip(_ category: ExploreCategory) -> some View { let isSelected = store.selectedCategory == category Button { store.send(.view(.categoryTapped(category))) - onTap() } label: { HStack(spacing: 4) { categoryIcon(for: category, isSelected: isSelected) @@ -183,8 +197,22 @@ private extension ExploreView { with proxy: ScrollViewProxy, animated: Bool = true ) { + let targetCategory: ExploreCategory + switch category { + case .all, .cafe: + targetCategory = .all + case .restaurant: + targetCategory = .cafe + case .activity: + targetCategory = .restaurant + case .etc: + targetCategory = .activity + @unknown default: + targetCategory = .all + } + let action = { - proxy.scrollTo(category == .all ? ExploreCategory.all : category, anchor: .leading) + proxy.scrollTo(targetCategory, anchor: .leading) } if animated { @@ -208,17 +236,10 @@ private extension ExploreView { .scaledToFit() .frame(width: 16, height: 16) case .cafe: - if isSelected { - Image(asset: .tapCaffee) + Image(asset: isSelected ? .tapCaffe : .cafe) .resizable() .scaledToFit() .frame(width: 16, height: 16) - } else { - Image(systemName: "cup.and.saucer.fill") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.gray600) - .frame(width: 16, height: 16) - } case .restaurant: Image(asset: isSelected ? .tapFood : .food) .resizable() diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index acaca08..8fb0cf2 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -25,7 +25,8 @@ public enum ImageAsset: String { case etc case food case tapAll - case tapCaffee + case cafe + case tapCaffe case tapGame case tapShopping case tapEtc