diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index dd2904f..fd55ac7 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -40,10 +40,14 @@ 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() } + // MARK: - 역 + .register(StationInterface.self) { StationRepositoryImpl() } 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/Data/API/Sources/API/Domain/TimeSpotDomain.swift b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift index dd7a1b6..c1ee8c3 100644 --- a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift +++ b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift @@ -13,6 +13,8 @@ public enum TimeSpotDomain { case auth case place case profile + case history + case station } extension TimeSpotDomain: DomainType { @@ -28,6 +30,10 @@ extension TimeSpotDomain: DomainType { return "api/v1/place" case .profile: return "api/v1/users" + case .history: + return "api/v1/histories" + case .station: + return "api/v1/stations" } } } 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 "" + } + } +} + 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..b59a212 --- /dev/null +++ b/Projects/Data/API/Sources/API/Station/StationAPI.swift @@ -0,0 +1,25 @@ +// +// StationAPI.swift +// API +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public enum StationAPI { + case allStation + case addFavoriteStation + case deleteFavoriteStation(deleteStationId: Int) + + public var description: String { + switch self { + case .allStation: + return "" + case .addFavoriteStation: + return "/favorites" + case .deleteFavoriteStation(let deleteStationId): + return "/favorites/\(deleteStationId)" + } + } +} 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..676baa9 --- /dev/null +++ b/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift @@ -0,0 +1,134 @@ +// +// 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 + + 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, + 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([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 { + 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 ) } } 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..355a65a --- /dev/null +++ b/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift @@ -0,0 +1,135 @@ +// +// 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 + + enum CodingKeys: String, CodingKey { + case favoriteStations + case nearbyStations + case stations + } + + public init( + favoriteStations: [StationSummaryResponseDTO], + nearbyStations: [StationSummaryResponseDTO], + stations: StationPageResponseDTO + ) { + self.favoriteStations = favoriteStations + 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 { + 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 + + 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, + 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 + } + + 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/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 + ) + } +} 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/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/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() } diff --git a/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift b/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift new file mode 100644 index 0000000..238f850 --- /dev/null +++ b/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift @@ -0,0 +1,61 @@ +// +// 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 authorizedProvider: MoyaProvider + private let publicProvider: MoyaProvider + + public init( + authorizedProvider: MoyaProvider = MoyaProvider.authorized, + publicProvider: MoyaProvider = MoyaProvider() + ) { + self.authorizedProvider = authorizedProvider + self.publicProvider = publicProvider + } + + 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 publicProvider.request(.allStation(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 authorizedProvider.request(.addFavoriteStation(body: body)) + return dto.toDomain() + } + + public func deleteFavoriteStation( + stationID: Int + ) async throws -> FavoriteStationMutationEntity { + let dto: FavoriteStationMutationDTOModel = try await authorizedProvider.request( + .deleteFavoriteStation(deleteStationId: stationID) + ) + return dto.toDomain() + } +} 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 } } diff --git a/Projects/Data/Service/Sources/Station/StationRequest.swift b/Projects/Data/Service/Sources/Station/StationRequest.swift new file mode 100644 index 0000000..4aa62e6 --- /dev/null +++ b/Projects/Data/Service/Sources/Station/StationRequest.swift @@ -0,0 +1,42 @@ +// +// 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 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..2ff5bb2 --- /dev/null +++ b/Projects/Data/Service/Sources/Station/StationService.swift @@ -0,0 +1,74 @@ +// +// 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 addFavoriteStation(body: AddFavoriteStationRequest) + case deleteFavoriteStation(deleteStationId: Int) +} + + +extension StationService: BaseTargetType { + public typealias Domain = TimeSpotDomain + + public var domain: TimeSpotDomain { + return .station + } + + public var urlPath: String { + switch self { + case .allStation: + return StationAPI.allStation.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: + 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 .addFavoriteStation(let body): + return body.toDictionary + case .deleteFavoriteStation: + return nil + } + } + + public var headers: [String : String]? { + switch self { + case .allStation: + return APIHeader.notAccessTokenHeader + case .addFavoriteStation, .deleteFavoriteStation: + return APIHeader.baseHeader + } + } +} 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 } diff --git a/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift new file mode 100644 index 0000000..da52692 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift @@ -0,0 +1,69 @@ +// +// 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 addFavoriteStation( + stationID: Int + ) async throws -> FavoriteStationMutationEntity { + FavoriteStationMutationEntity( + code: 201, + message: "즐겨찾기가 성공적으로 생성되었습니다." + ) + } + + public func deleteFavoriteStation( + stationID: 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..0fb1e57 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift @@ -0,0 +1,46 @@ +// +// 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 addFavoriteStation( + stationID: Int + ) async throws -> FavoriteStationMutationEntity + + func deleteFavoriteStation( + stationID: 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/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/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/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/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/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 new file mode 100644 index 0000000..ca6122c --- /dev/null +++ b/Projects/Domain/Entity/Sources/TrainStation/Station.swift @@ -0,0 +1,216 @@ +// +// 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 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 } + + public var displayName: String { + switch self { + case .seoul: + 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: + return "대전" + case .gangneung: + return "강릉" + case .cheongnyangri: + return "청량리" + case .manjong: + return "만종" + case .pyeongchang: + return "평창" + } + } + + public var homeTitle: String { + switch self { + case .seoul: + 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: + return "DAEJEON" + case .gangneung: + return "GANGNEUNG" + case .cheongnyangri: + return "CHEONGNYANGNI" + case .manjong: + return "만종" + case .pyeongchang: + return "평창" + } + } + + public init?(displayName: String) { + let normalized = displayName + .replacingOccurrences(of: "역", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + switch normalized { + case "서울": + 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 "대전": + self = .daejeon + case "강릉": + self = .gangneung + case "청량리": + self = .cheongnyangri + case "만종": + self = .manjong + case "평창": + self = .pyeongchang + default: + return nil + } + } +} 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/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( diff --git a/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift new file mode 100644 index 0000000..69661e6 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift @@ -0,0 +1,56 @@ +// +// 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 addFavoriteStation( + stationID: Int + ) async throws -> FavoriteStationMutationEntity { + try await repository.addFavoriteStation(stationID: stationID) + } + + public func deleteFavoriteStation( + stationID: Int + ) async throws -> FavoriteStationMutationEntity { + try await repository.deleteFavoriteStation(stationID: stationID) + } +} + +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 } + } +} 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/Explore/Reducer/ExploreReducer.swift b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift index cd3a2f1..1ceafef 100644 --- a/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift +++ b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift @@ -23,7 +23,9 @@ 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 // 길찾기 관련 상태 public var selectedDestination: Destination? @@ -33,6 +35,7 @@ public struct ExploreReducer: Sendable { // 지도 카메라 제어 public var shouldReturnToCurrentLocation: Bool = false + public var selectedCategory: ExploreCategory = .all public init() {} } @@ -64,6 +67,8 @@ public struct ExploreReducer: Sendable { case retryLocationPermission case requestFullAccuracy case openSettings + case searchTextChanged(String) + case categoryTapped(ExploreCategory) // 길찾기 관련 액션 case searchRouteToGangnam case clearRoute @@ -135,7 +140,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 +150,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 +170,14 @@ extension ExploreReducer { } } + case .searchTextChanged(let text): + state.searchText = text + return .none + + case .categoryTapped(let category): + state.selectedCategory = category + return .none + // 길찾기 관련 액션 case .searchRouteToGangnam: guard let currentLocation = state.currentLocation else { @@ -211,32 +223,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 +273,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 +366,7 @@ extension ExploreReducer { switch alertAction { case .confirmLocationPermission: state.alert = nil - return .send(.view(.requestLocationPermission)) + return .none case .cancelLocationPermission: state.alert = nil @@ -430,6 +399,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 d0f9043..8d37d3d 100644 --- a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift +++ b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift @@ -9,232 +9,256 @@ import SwiftUI import ComposableArchitecture import CoreLocation + +import DesignSystem import Entity public struct ExploreView: View { @Bindable var store: StoreOf + @Environment(\.dismiss) private var dismiss + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ZStack { + mapView() - public init(store: StoreOf) { - self.store = store + VStack(spacing: 0) { + headerSection() + .padding(.top, 8) + .padding(.horizontal, 20) + + Spacer() + + currentLocationButton() + } } + .onAppear { + store.send(.view(.onAppear)) + } + .onDisappear { + store.send(.view(.onDisappear)) + } + .alert($store.scope(state: \.alert, action: \.scope.alert)) + } +} - 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)) - } - ) - } +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) + } - // 🎯 네이버 스타일 위치 버튼 (우측 하단) - 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) - } - } + @ViewBuilder + func headerSection() -> some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + backButton() + searchBar() + } - // 길찾기 컨트롤 UI - VStack { - // 경로 정보 표시 (상단으로 이동) - if let routeInfo = store.routeInfo, - let destination = store.selectedDestination { - routeInfoCard(routeInfo: routeInfo, destination: destination) - .padding(.horizontal) - .padding(.top, 50) // 상단 패딩으로 변경 - } - - 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) - } - } + 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(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) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() } - .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)) + .shadow(color: .black.opacity(0.08), radius: 12, y: 2) + } + + @ViewBuilder + func categoryScrollView() -> some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(ExploreCategory.allCases, id: \.self) { category in + categoryChip(category) + .id(category) } + } + .padding(.horizontal, 2) } .onAppear { - store.send(.view(.onAppear)) + scrollToCategory(store.selectedCategory, with: proxy, animated: false) } - .onDisappear { - store.send(.view(.onDisappear)) + .onChange(of: store.selectedCategory) { _, category in + DispatchQueue.main.async { + scrollToCategory(category, with: proxy) + } } - .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() - .background(Color.white.opacity(0.95)) - .cornerRadius(12) - .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 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) + } - // 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) - } + @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) } + } - private func formatCurrency(_ amount: Int) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return "\(formatter.string(from: NSNumber(value: amount)) ?? "\(amount)")원" + func scrollToCategory( + _ category: ExploreCategory, + 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(targetCategory, anchor: .leading) + } + + if animated { + withAnimation(.easeInOut(duration: 0.2)) { + action() + } + } else { + action() + } + } + + @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: + Image(asset: isSelected ? .tapCaffe : .cafe) + .resizable() + .scaledToFit() + .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 72b0fa3..49d0b7b 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -7,16 +7,32 @@ 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() {} + // 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() { @@ -26,16 +42,33 @@ 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 selectedStationID: Int? + var selectedStationName: String = "" + var hasSelectedStation: Bool = false + var customAlertMode: CustomAlertMode? = nil + var hasAppearedOnce: Bool = false + var shouldResetAfterExplore: Bool = false + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + } + + 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 +79,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 +121,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,16 +140,116 @@ 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 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 = String(row.stationID) + $0.travelStationName = row.stationName + } + return .merge( + .cancel(id: TrainStationFeature.CancelID.checkAccessToken), + .cancel(id: TrainStationFeature.CancelID.fetchStations), + .cancel(id: TrainStationFeature.CancelID.favoriteMutation), + state.shouldShowDepartureWarningToast ? .send(.inner(.showDepartureWarningToast)) : .none + ) + + case .dismiss: + state.isSelected = false + return .merge( + .cancel(id: TrainStationFeature.CancelID.checkAccessToken), + .cancel(id: TrainStationFeature.CancelID.fetchStations), + .cancel(id: TrainStationFeature.CancelID.favoriteMutation) + ) + + 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, + selectedStationID: state.selectedStationID + ) + return .none + case .departureTimeButtonTapped: + state.currentTime = now + if state.departureTime < state.currentTime { + state.departureTime = state.currentTime + } state.departureTimePickerVisible.toggle() return .none @@ -107,7 +258,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 +275,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 +310,12 @@ extension HomeFeature { switch action { case .presentProfile: return .none + + case .presentAuth: + return .none + + case .presentExplore: + return .none } } @@ -132,15 +323,99 @@ 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.selectedStationID = nil + state.selectedStationName = "" + state.hasSelectedStation = false + state.$userSession.withLock { + $0.travelID = "" + $0.travelStationName = "" + } + 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 +436,18 @@ 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) + hasher.combine(userSession) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index c0804a9..704f350 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -15,6 +15,31 @@ import ComposableArchitecture public struct HomeView: View { @Bindable var store: StoreOf + // MARK: - Layout Constants + 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 + } + + 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 } @@ -38,6 +63,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(HomeFeature.Strings.insufficientWaitTime) + } } } @@ -47,15 +92,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: Layout.Hero.height, alignment: .top) .scaleEffect(1.06, anchor: .top) .ignoresSafeArea(edges: .top) @@ -74,25 +117,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: Layout.Hero.height) .background(.clear) .clipShape( UnevenRoundedRectangle( cornerRadii: .init( - bottomLeading: 40, - bottomTrailing: 40 + bottomLeading: Layout.Hero.cornerRadius, + bottomTrailing: Layout.Hero.cornerRadius ) ) ) if store.departureTimePickerVisible { departureTimePickerView() - .offset(x: geometry.size.width - 8 - 176 - 8, y: 492) + .offset(x: geometry.size.width + Layout.TimePicker.offset.horizontal, y: Layout.TimePicker.offset.vertical) .zIndex(2) } } } - .frame(height: heroHeight) + .frame(height: Layout.Hero.height) } @ViewBuilder @@ -101,7 +144,7 @@ extension HomeView { Spacer() Button { - store.send(.delegate(.presentProfile)) + store.send(.view(.profileButtonTapped)) } label: { Image(asset: .profile) .resizable() @@ -118,25 +161,30 @@ 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 fileprivate func selectTrainTimeView() -> some View { HStack(alignment: .top, spacing: 10) { timeCapsuleView( - title: "현재 시간", + title: HomeFeature.Strings.currentTime, time: store.currentTime.formattedKoreanTime(), timeColor: .gray900, backgroundColor: .gray100 @@ -146,7 +194,7 @@ extension HomeView { store.send(.view(.departureTimeButtonTapped)) } label: { timeCapsuleView( - title: "출발 시간", + title: HomeFeature.Strings.departureTime, time: store.isDepartureTimeSet ? store.departureTime.formattedKoreanTime() : store.currentTime.formattedKoreanTime(), @@ -157,15 +205,16 @@ extension HomeView { .buttonStyle(.plain) .frame(maxWidth: .infinity) } - .padding(.horizontal, 8) - .padding(.top, 4) + .padding(.horizontal, 24) + .padding(.top, 8) } @ViewBuilder fileprivate func departureTimePickerView() -> some View { DatePicker( - "출발 시간 선택", + HomeFeature.Strings.departureTimeSelection, selection: $store.departureTime, + in: store.currentTime..., displayedComponents: [.hourAndMinute] ) .datePickerStyle(.wheel) @@ -174,11 +223,11 @@ extension HomeView { .onChange(of: store.departureTime) { _, newValue in store.send(.view(.departureTimeChanged(newValue))) } - .frame(height: 180) + .frame(height: Layout.TimePicker.height) .clipped() - .frame(width: 176) + .frame(width: Layout.TimePicker.width) .background(.gray300) - .clipShape(RoundedRectangle(cornerRadius: 24)) + .clipShape(RoundedRectangle(cornerRadius: Layout.TimePicker.cornerRadius)) } @ViewBuilder @@ -198,9 +247,9 @@ extension HomeView { .foregroundStyle(timeColor) } .frame(maxWidth: .infinity) - .frame(height: 77) + .frame(height: Layout.TimeCapsule.height) .background(backgroundColor) - .cornerRadius(32) + .cornerRadius(Layout.TimeCapsule.cornerRadius) } @@ -215,7 +264,7 @@ extension HomeView { .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) .frame(height: 69) - Text("HOURS") + Text(HomeFeature.Strings.hours) .pretendardCustomFont(textStyle: .caption) .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) @@ -238,7 +287,7 @@ extension HomeView { .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) .frame(height: 69) - Text("MINUTES") + Text(HomeFeature.Strings.minutes) .pretendardCustomFont(textStyle: .caption) .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) @@ -249,7 +298,7 @@ extension HomeView { } .padding(.vertical, 21) .background( - RoundedRectangle(cornerRadius: 36) + RoundedRectangle(cornerRadius: Layout.TimeDisplay.cornerRadius) .fill(.white) ) .padding(.horizontal, 24) @@ -258,10 +307,12 @@ extension HomeView { @ViewBuilder fileprivate func exploreNearbyButton() -> some View { CustomButton( - action: {}, - title: "주변 탐색 시작하기", + action: { + store.send(.view(.exploreNearbyButtonTapped)) + }, + title: HomeFeature.Strings.exploreNearby, config: CustomButtonConfig.create(), - isEnable: false + isEnable: store.isExploreNearbyEnabled ) .padding(.horizontal, 24) } diff --git a/Projects/Presentation/Home/Sources/LocationPermissionManager.swift b/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift similarity index 95% rename from Projects/Presentation/Home/Sources/LocationPermissionManager.swift rename to Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift index 449a315..8ec7a66 100644 --- a/Projects/Presentation/Home/Sources/LocationPermissionManager.swift +++ b/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift @@ -65,7 +65,11 @@ public final class LocationPermissionManager: NSObject, ObservableObject { // async/await을 사용한 위치 권한 요청 public func requestLocationPermission() async -> CLAuthorizationStatus { - guard CLLocationManager.locationServicesEnabled() else { + let isLocationServicesEnabled = await Task.detached { + CLLocationManager.locationServicesEnabled() + }.value + + guard isLocationServicesEnabled else { locationError = "위치 서비스가 비활성화되어 있습니다. 설정에서 활성화해 주세요." return .denied } @@ -146,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 } // 권한 상태 문자열 @@ -217,4 +223,4 @@ extension LocationPermissionManager: CLLocationManagerDelegate { } } } -} \ 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..5ad93aa --- /dev/null +++ b/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift @@ -0,0 +1,40 @@ +// +// StationRowModel.swift +// Home +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation +import Entity + +public struct StationRowModel: Identifiable, Equatable, Hashable { + public let id: String + 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 new file mode 100644 index 0000000..7d2c49d --- /dev/null +++ b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift @@ -0,0 +1,349 @@ +// +// TrainStationFeature.swift +// Home +// +// Created by Wonji Suh on 3/26/26. +// + + +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() {} + + public enum CancelID: Hashable { + case checkAccessToken + case fetchStations + case favoriteMutation + } + + @ObservableState + public struct State: Equatable { + var searchText: String = "" + var shouldShowFavoriteSection: Bool = false + var selectedStation: Station + var selectedStationID: Int? + var favoriteRows: [StationRowModel] = [] + var nearbyRows: [StationRowModel] = [] + var majorRows: [StationRowModel] = [] + var isLoading: Bool = false + var errorMessage: String? + + public init( + selectedStation: Station = .seoul, + selectedStationID: Int? = nil + ) { + self.selectedStation = selectedStation + self.selectedStationID = selectedStationID + } + } + + + 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(StationRowModel) + case favoriteButtonTapped(StationRowModel) + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + case checkAccessToken + case fetchStations + case addFavoriteStation(Int) + case deleteFavoriteStation(Int) + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + case accessTokenChecked(Bool) + case fetchStationsResponse(StationListEntity) + case fetchStationsFailed(String) + case addFavoriteStationResponse + case addFavoriteStationFailed(String) + case deleteFavoriteStationResponse + case deleteFavoriteStationFailed(String) + } + + //MARK: - DelegateAction + public enum DelegateAction: Equatable { + case stationSelected(StationRowModel) + } + + + 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: + state.isLoading = true + return .merge( + .send(.async(.checkAccessToken)), + .send(.async(.fetchStations)) + ) + + case .stationTapped(let row): + guard let station = row.station else { return .none } + state.selectedStation = station + state.selectedStationID = row.stationID + return .send(.delegate(.stationSelected(row))) + case .favoriteButtonTapped(let row): + guard state.shouldShowFavoriteSection else { return .none } + if row.isFavorite { + return .send(.async(.deleteFavoriteStation(row.stationID))) + } else { + return .send(.async(.addFavoriteStation(row.stationID))) + } + } + } + + 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))) + } + .cancellable(id: CancelID.checkAccessToken) + 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))) + } + } + .cancellable(id: CancelID.fetchStations) + 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))) + } + } + .cancellable(id: CancelID.favoriteMutation, cancelInFlight: true) + case .deleteFavoriteStation(let stationID): + return .run { [stationUseCase] send in + do { + _ = try await stationUseCase.deleteFavoriteStation(stationID: stationID) + await send(.inner(.deleteFavoriteStationResponse)) + } catch { + await send(.inner(.deleteFavoriteStationFailed(error.localizedDescription))) + } + } + .cancellable(id: CancelID.favoriteMutation, cancelInFlight: true) + } + } + + 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 + case .fetchStationsResponse(let entity): + state.favoriteRows = makeFavoriteRows(entity.favoriteStations) + state.nearbyRows = makeNearbyRows(entity.nearbyStations) + state.majorRows = makeMajorRows(entity.stations.content) + applyFavoriteState(state: &state) + state.isLoading = false + return .none + case .fetchStationsFailed(let message): + state.errorMessage = message + state.isLoading = false + return .none + case .addFavoriteStationResponse: + return .send(.async(.fetchStations)) + case .addFavoriteStationFailed(let message): + state.errorMessage = message + return .none + case .deleteFavoriteStationResponse: + return .send(.async(.fetchStations)) + case .deleteFavoriteStationFailed(let message): + state.errorMessage = message + return .none + } + } +} + +extension TrainStationFeature.State: Hashable {} + +private extension TrainStationFeature { + 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: "favorite-\(station.stationID)", + favoriteID: station.stationID, + station: Station(displayName: normalizedName), + stationID: station.stationID, + stationName: normalizedName, + badges: station.lines, + distanceText: nil, + isFavorite: true + ) + } + } + + 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: "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 + .sorted { normalizedStationName($0.name) < normalizedStationName($1.name) } + .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 applyFavoriteState(state: inout State) { + let favoriteNameMap = Dictionary( + uniqueKeysWithValues: state.favoriteRows.map { + (normalizedStationName($0.stationName), $0.stationID) + } + ) + + 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 new file mode 100644 index 0000000..2fbb876 --- /dev/null +++ b/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift @@ -0,0 +1,436 @@ +// +// 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) { + 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 + ) + } + + stationSectionView( + title: "가까운 역", + systemIcon: nil, + assetIcon: .mapSharp, + iconColor: .gray550, + stations: filteredNearbyStations + ) + + stationSectionView( + title: "주요 역", + systemIcon: nil, + assetIcon: .subway, + iconColor: .gray550, + stations: filteredMajorStations + ) + } + .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)) + } + } +} + +extension TrainStationView { + private var filteredFavoriteStations: [StationRowModel] { + filterRows(store.favoriteRows) + } + + private var filteredNearbyStations: [StationRowModel] { + filterRows(store.nearbyRows) + } + + private var filteredMajorStations: [StationRowModel] { + filterRows(store.majorRows) + } + + private func filterRows(_ rows: [StationRowModel]) -> [StationRowModel] { + let query = normalizedSearchText(store.searchText) + + guard !query.isEmpty else { + return rows + } + + return rows.filter { + 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("출발역 선택") + .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 { + HStack(spacing: 12) { + Button { + if row.station != nil { + store.send(.view(.stationTapped(row))) + modalDismiss() + } + } label: { + 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) + } + + 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) + } + } + } + } + + Spacer() + + if let distance = row.distanceText { + Text(distance) + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.gray500) + .lineLimit(1) + } + } + .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 ? .orange800 : .gray550) + .frame(width: 20, height: 20) + } + .buttonStyle(.plain) + } + .padding(.leading, 20) + .padding(.trailing, 24) + .padding(.vertical, 18) + } + + @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()) + } + + @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) + } +} + +private extension View { + func skeletonShimmer() -> some View { + modifier(TrainStationSkeletonShimmerModifier()) + } +} + +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 + } + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift b/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift index ad44357..e751f6b 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 @@ -75,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/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 ) } diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 73c811a..8fb0cf2 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -19,6 +19,20 @@ public enum ImageAsset: String { case arrowRight case leftArrow case lineHeight + case all + case game + case shopping + case etc + case food + case tapAll + case cafe + case tapCaffe + case tapGame + case tapShopping + case tapEtc + case tapFood + case location + // MARK: - 지도 @@ -33,6 +47,7 @@ public enum ImageAsset: String { case logo case loginlogo + case warning case setting case time @@ -40,6 +55,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/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 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 + ) + ) } }