Skip to content

Commit b755313

Browse files
authored
[NL-68]: 번호 상세 화면 API (#44)
* [NL-68]: 번호 상세 화면 API 연동 * [NL-68]: 타이머 로직 구현 * [NL-68]: footer 영역 버튼 로직 구현 * [NL-68]: navigation title 설정 로직 구현
1 parent f48c773 commit b755313

10 files changed

+312
-88
lines changed

Feature/Home/Sources/Home/HomeViewModel.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ public final class HomeViewModel {
5050
with: [:])
5151
}
5252
case .recommended:
53-
// TODO: 번호 상세
54-
break
53+
Task { @MainActor in
54+
homeRouter.navigate(
55+
to: HomeRoute.recommendationDetail, how: .push(hidesBottomBarWhenPushed: true),
56+
with: ["shouldCreateRecommendation": false])
57+
}
5558
case .needsResultCheck:
5659
// TODO: 결과
5760
break

Feature/Home/Sources/HomeAssembly.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ public final class HomeAssembly: Assembly {
1313
container.register(HomeService.self) { _ in
1414
return HomeService()
1515
}
16+
container.register(RecommendationDetailService.self) { _ in
17+
return RecommendationDetailService()
18+
}
1619
container.register(HomeRouter.self) { _ in
1720
return HomeRouter()
1821
}

Feature/Home/Sources/HomeRouter.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ enum HomeRoute {
1414
}
1515

1616
final class HomeRouter: Routable {
17-
private var factories: [HomeRoute: () -> UIViewController] = [:]
17+
// TODO: 생성 시점 data 주입을 위한 구조 논의 필요
18+
private var factories: [HomeRoute: ([String: Any]) -> UIViewController] = [:]
1819

1920
public nonisolated init() {
2021
Task { @MainActor in
@@ -24,19 +25,20 @@ final class HomeRouter: Routable {
2425

2526
public func setFactories() {
2627
self.factories = [
27-
.recommendationLoading: {
28+
.recommendationLoading: { _ in
2829
return RecommendationLoadingViewController()
2930
},
30-
.recommendationDetail: {
31-
return RecommendationDetailViewController(viewModel: RecommendationDetailViewModel())
31+
.recommendationDetail: { data in
32+
let shouldCreateRecommendation = data["shouldCreateRecommendation"] as? Bool
33+
return RecommendationDetailViewController(viewModel: RecommendationDetailViewModel(shouldCreateRecommendation: shouldCreateRecommendation ?? true))
3234
},
3335
]
3436
}
3537

3638
public func navigate(to route: Any, how: NavigateType, with data: [String: Any]) {
3739
guard let homeRoute = route as? HomeRoute else { return }
3840
guard let factory = factories[homeRoute] else { return }
39-
let viewController = factory()
41+
let viewController = factory(data)
4042
manageViewController(viewController, how: how)
4143
}
4244
}

Feature/Home/Sources/Home/HomeTarget.swift renamed to Feature/Home/Sources/HomeTarget.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ enum HomeTarget {
2121
var headers: [String: String]? { nil }
2222
let userID: String
2323
}
24+
25+
struct CreateLottoRecommendation: BaseTargetType {
26+
27+
typealias Response = LottoRecommendationDTO
28+
29+
var path: String { "users/\(userID)/lotto-recommendation" }
30+
var httpTask: HTTPTask { .requestPlain }
31+
var httpMethod: HTTPMethod { .post }
32+
var headers: [String: String]? { nil }
33+
let userID: String
34+
}
2435

2536
struct GetUserDailyFortunes: BaseTargetType {
2637

Feature/Home/Sources/RecommendationDetail/AIAnalysisResultCollectionViewCell.swift

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Extension
1010
import UIKit
1111

1212
struct AIAnalysisResultCollectionViewCellModel: RecommendationDetailCellModel {
13-
let description: NSAttributedString // TODO: 확인 필요
13+
let description: String
1414
let items: [NumberRecommendationItem]
1515
}
1616

@@ -32,7 +32,7 @@ final class AIAnalysisResultCollectionViewCell: UICollectionViewCell {
3232
$0.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
3333
}
3434
private lazy var descriptionLabel = UILabel().then {
35-
$0.style = Typography.Body_14_B
35+
$0.style = Typography.Body_14_B.lineHeightMultiple(1.2)
3636
$0.textColor = STColors.gray1.color
3737
$0.numberOfLines = .zero
3838
}
@@ -70,7 +70,7 @@ final class AIAnalysisResultCollectionViewCell: UICollectionViewCell {
7070
}
7171

7272
func update(with model: AIAnalysisResultCollectionViewCellModel) {
73-
descriptionLabel.attributedText = model.description
73+
descriptionLabel.styledText = model.description
7474

7575
recommendationStackView.arrangedSubviews.forEach {
7676
$0.removeFromSuperview()
@@ -85,17 +85,8 @@ final class AIAnalysisResultCollectionViewCell: UICollectionViewCell {
8585

8686
@available(iOS 17.0, *)
8787
#Preview {
88-
let description = "콩떡님은 화(火) 기운이 강하여\n‘지존 만수르’ 예요"
89-
let attributedString = NSMutableAttributedString(
90-
string: description,
91-
attributes: Typography.Body_14_B.color(STColors.gray1.color).attributes
92-
)
93-
if let range = (description as NSString).range(of: "‘지존 만수르’") as NSRange? {
94-
attributedString.addAttributes(
95-
Typography.Body_14_B.color(STColors.primary2.color).attributes, range: range)
96-
}
9788
let cellModel = AIAnalysisResultCollectionViewCellModel(
98-
description: attributedString,
89+
description: "콩떡님은 화(火) 기운이 강하여\n‘지존 만수르’ 예요",
9990
items: [
10091
.init(title: "화(火) 기운과 잘 맞는 숫자", numbers: [9, 11]),
10192
.init(title: "재물운 좋을 때 잘 나오는 숫자", numbers: [24, 33]),

Feature/Home/Sources/RecommendationDetail/NumberRecommendationCollectionViewCell.swift

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
// Created by ttozzi on 8/7/25.
66
//
77

8+
import Base
9+
import Combine
810
import DesignSystem
911
import Extension
1012
import UIKit
@@ -13,10 +15,30 @@ struct NumberRecommendationCollectionViewCellModel: RecommendationDetailCellMode
1315
let roundText: String
1416
let title: String
1517
let numbers: [Int]
16-
let timeUntilDraw: String // TODO: 확인 필요
18+
var secondsUntilResult: Int { // TODO: 기기 시간 설정을 바꾼 경우, 오후 8시 35분이 지났으나 서버에서 결과 조회가 준비되지 않은 경우 논의 필요
19+
var calendar = Calendar(identifier: .gregorian)
20+
calendar.timeZone = .current
21+
calendar.locale = Locale(identifier: "ko_KR")
22+
calendar.firstWeekday = 2
23+
calendar.minimumDaysInFirstWeek = 4
24+
25+
let now = Date()
26+
guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now)),
27+
let saturday = calendar.date(byAdding: .day, value: 5, to: weekStart),
28+
let target = calendar.date(bySettingHour: 20, minute: 35, second: 0, of: saturday) else {
29+
return 0
30+
}
31+
32+
let diff = target.timeIntervalSince(now)
33+
if diff >= 0 {
34+
return Int(diff)
35+
}
36+
let next = calendar.date(byAdding: .day, value: 7, to: target)!
37+
return max(0, Int(next.timeIntervalSince(now)))
38+
}
1739
}
1840

19-
final class NumberRecommendationCollectionViewCell: UICollectionViewCell {
41+
final class NumberRecommendationCollectionViewCell: BaseCollectionViewCell {
2042

2143
private lazy var contentStackView = UIStackView().then {
2244
$0.spacing = 16
@@ -47,7 +69,10 @@ final class NumberRecommendationCollectionViewCell: UICollectionViewCell {
4769
$0.textColor = STColors.gray1.color
4870
$0.textAlignment = .right
4971
}
50-
72+
private var countdownCancellable: AnyCancellable?
73+
private let timerFinishedSubject = PassthroughSubject<Void, Never>()
74+
var timerFinished: AnyPublisher<Void, Never> { timerFinishedSubject.eraseToAnyPublisher() }
75+
5176
override init(frame: CGRect) {
5277
super.init(frame: frame)
5378
setupUI()
@@ -57,6 +82,12 @@ final class NumberRecommendationCollectionViewCell: UICollectionViewCell {
5782
fatalError("init(coder:) has not been implemented")
5883
}
5984

85+
override func prepareForReuse() {
86+
super.prepareForReuse()
87+
countdownCancellable?.cancel()
88+
countdownCancellable = nil
89+
}
90+
6091
private func setupUI() {
6192
contentView.backgroundColor = STColors.white.color
6293
contentView.layer.cornerRadius = 12
@@ -109,7 +140,54 @@ final class NumberRecommendationCollectionViewCell: UICollectionViewCell {
109140
ball.number = String(number)
110141
numberBallStackView.addArrangedSubview(ball)
111142
}
112-
timeUntilDrawLabel.styledText = model.timeUntilDraw
143+
startCountdown(seconds: model.secondsUntilResult)
144+
}
145+
146+
private func startCountdown(seconds: Int) {
147+
countdownCancellable?.cancel()
148+
149+
let secs = max(0, seconds)
150+
let target = Date().addingTimeInterval(TimeInterval(secs))
151+
152+
countdownCancellable = Timer.publish(every: 1, on: .main, in: .common)
153+
.autoconnect()
154+
.handleEvents(receiveSubscription: { [weak self] _ in
155+
self?.timeUntilDrawLabel.styledText = self?.countdownText(from: Int(target.timeIntervalSinceNow))
156+
})
157+
.map { _ in
158+
return max(0, Int(target.timeIntervalSinceNow))
159+
}
160+
.sink { [weak self] remaining in
161+
self?.timeUntilDrawLabel.styledText = self?.countdownText(from: remaining)
162+
if remaining == 0 {
163+
self?.countdownCancellable?.cancel()
164+
self?.timeUntilDrawLabel.textColor = STColors.red3.color
165+
self?.timerFinishedSubject.send(())
166+
}
167+
}
168+
}
169+
170+
private func countdownText(from seconds: Int) -> String {
171+
if seconds <= 0 {
172+
return "0초"
173+
}
174+
let d = seconds / 86_400
175+
let h = (seconds % 86_400) / 3_600
176+
let m = (seconds % 3_600) / 60
177+
let s = seconds % 60
178+
179+
var parts: [String] = []
180+
if d > 0 {
181+
parts.append("\(d)")
182+
}
183+
if h > 0 || d > 0 {
184+
parts.append("\(h)시간")
185+
}
186+
if m > 0 || h > 0 || d > 0 {
187+
parts.append("\(m)")
188+
}
189+
parts.append("\(s)")
190+
return parts.joined(separator: " ")
113191
}
114192

115193
private func makeTextStackView(title: String, descriptionLabel: UILabel) -> UIStackView {
@@ -138,8 +216,7 @@ final class NumberRecommendationCollectionViewCell: UICollectionViewCell {
138216
let cellModel = NumberRecommendationCollectionViewCellModel(
139217
roundText: "1181회",
140218
title: "콩떡님을 위한 로또 번호 추천",
141-
numbers: [9, 11, 18, 24, 33, 42],
142-
timeUntilDraw: "6일 2시간 59분 32초"
219+
numbers: [9, 11, 18, 24, 33, 42]
143220
)
144221
let cell = NumberRecommendationCollectionViewCell()
145222
cell.update(with: cellModel)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//
2+
// RecommendationDetailService.swift
3+
// Home
4+
//
5+
// Created by ttozzi on 8/17/25.
6+
//
7+
8+
import Auth
9+
import DIInjector
10+
import Foundation
11+
import NetworkCore
12+
13+
struct RecommendationDetailService {
14+
15+
@Injected private var userDataManager: UserDataManager
16+
@Injected private var networkProvider: NetworkProvider
17+
private var username: String? { userDataManager.user?.name }
18+
var navigationTitle: String {
19+
guard let username else {
20+
return "로또 번호"
21+
}
22+
return "\(username)의 로또 번호"
23+
}
24+
25+
func createRecommendation() async throws -> [any RecommendationDetailCellModel] {
26+
let target = HomeTarget.CreateLottoRecommendation(userID: userDataManager.userID)
27+
let lottoRecommendation = try await networkProvider.request(target: target)
28+
return try makeSections(from: lottoRecommendation)
29+
}
30+
31+
func fetchRecommendation() async throws -> [any RecommendationDetailCellModel] {
32+
let target = HomeTarget.GetLottoRecommendation(
33+
userID: userDataManager.userID)
34+
let lottoRecommendation = try await networkProvider.request(target: target)
35+
return try makeSections(from: lottoRecommendation)
36+
}
37+
38+
private func makeSections(from recommendation: LottoRecommendationDTO) throws -> [any RecommendationDetailCellModel] {
39+
let title = if let username = userDataManager.user?.name {
40+
"\(username)님을 위한 로또 번호 추천"
41+
} else {
42+
"로또 번호 추천"
43+
}
44+
guard let content = recommendation.content else {
45+
throw NSError() // TODO: 예외 처리
46+
}
47+
return [
48+
NumberRecommendationCollectionViewCellModel(
49+
roundText: "\(recommendation.round)",
50+
title: title,
51+
numbers: [
52+
content.num1,
53+
content.num2,
54+
content.num3,
55+
content.num4,
56+
content.num5,
57+
content.num6
58+
]
59+
),
60+
AIAnalysisResultCollectionViewCellModel(
61+
description: content.reason,
62+
items: [
63+
.init(title: "\(content.strongElement) 기운과 잘 맞는 숫자", numbers: [content.num1, content.num2]),
64+
.init(title: "재물운 좋을 때 잘 나오는 숫자", numbers: [content.num3, content.num4]),
65+
.init(title: "최근 자주 나온 번호", numbers: [content.num5, content.num6]),
66+
]
67+
),
68+
DescriptionListCollectionViewCellModel(
69+
descriptions: [
70+
"요즘 많이 나오는 번호가 들어 있소",
71+
"연속 숫자 3개 이상 없이 안정적인 조합이오",
72+
"홀짝이 고르게 섞였소이다",
73+
"끝자리가 같은 수가 한 쌍 있소",
74+
]
75+
),
76+
AvoidNumberCollectionViewCellModel(
77+
items: [
78+
.init(title: "\(content.weakElement) 기운과\n상충하는 숫자", numbers: content.coldNums),
79+
.init(title: "최근 100회 동안\n거의 안 나온 숫자", numbers: content.infrequentNums),
80+
]
81+
),
82+
]
83+
}
84+
}

0 commit comments

Comments
 (0)