Skip to content

Commit d2f5335

Browse files
authored
feat - 온보딩 리뉴얼 (#76)
* feat(Setup): Initial setup Page settings * feat(PolicyAgreement): 로고 배치 * feat(PolicyDocumentSheet): UI 개선 * feat(NameSetup): UI 개선 * feat(Name,Tag): 이름, 태그 UI 및 텍스트 수정 * feat(InitialSetupFlow): 건너뛰기 버튼 UI 통일 * feat(Setup): 튜토리얼 페이지 UI/UX 최종 업데이트
1 parent 8189ccc commit d2f5335

14 files changed

Lines changed: 3054 additions & 34 deletions

File tree

.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(awk '/private struct TutorialDiaryDetailScreen/,/private struct TutorialNotificationScreen/' /Users/ibyeongchan/Desktop/KillingPart/KillingPoint-iOS/KillingPart/Views/Screens/Setup/InitialSetupFlowView.swift)"
5+
]
6+
}
7+
}

KillingPart/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
<string>$(KAKAO_NATIVE_APP_KEY)</string>
1111
<key>MUSIC_BASE_URL</key>
1212
<string>$(MUSIC_BASE_URL)</string>
13+
<key>APP_STORE_URL</key>
14+
<string>$(APP_STORE_URL)</string>
1315
<key>LSApplicationQueriesSchemes</key>
1416
<array>
1517
<string>kakaokompassauth</string>

KillingPart/Models/AppFlowStep.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22

33
enum AppFlowStep {
44
case splash
5-
case onboarding
65
case login
6+
case setup
77
case main
88
}

KillingPart/Models/UserModel.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,55 @@ struct UpdateMyTagRequest: Encodable {
153153
let tag: String
154154
}
155155

156+
struct UpdateMyUsernameRequest: Encodable {
157+
let username: String
158+
}
159+
160+
struct UserInitSettingsResponse: Decodable {
161+
let app: UserAppUpdateStatus
162+
let needsPolicyAgreement: Bool
163+
let needsTagSetup: Bool
164+
let policies: [UserPolicyStatus]
165+
}
166+
167+
struct UserAppUpdateStatus: Decodable {
168+
let needsForceUpdate: Bool
169+
let needsOptionalUpdate: Bool
170+
}
171+
172+
struct UserPolicyStatus: Decodable, Identifiable {
173+
let policyType: UserPolicyType
174+
let required: Bool
175+
let agreed: Bool
176+
let currentRevision: Int
177+
let latestRevision: Int
178+
179+
var id: UserPolicyType { policyType }
180+
}
181+
182+
enum UserPolicyType: String, Codable, CaseIterable {
183+
case serviceTerms = "SERVICE_TERMS"
184+
case privacy = "PRIVACY"
185+
186+
var displayTitle: String {
187+
switch self {
188+
case .serviceTerms:
189+
return "서비스 이용약관"
190+
case .privacy:
191+
return "개인정보 처리방침"
192+
}
193+
}
194+
}
195+
196+
struct PolicyAgreementRequest: Encodable {
197+
let agreements: [PolicyAgreementItem]
198+
}
199+
200+
struct PolicyAgreementItem: Encodable {
201+
let policyType: UserPolicyType
202+
let agreed: Bool
203+
}
204+
156205
private extension KeyedDecodingContainer {
157206
func decodeFlexibleBoolIfPresent(forKey key: K) -> Bool? {
158207
if let boolValue = try? decodeIfPresent(Bool.self, forKey: key) {

KillingPart/Services/UserService.swift

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ import Foundation
22

33
protocol UserServicing {
44
func fetchMyUser() async throws -> UserModel
5+
func fetchInitSettings() async throws -> UserInitSettingsResponse
6+
func submitPolicyAgreement(agreements: [PolicyAgreementItem]) async throws
57
func fetchUserStatics(userId: Int) async throws -> UserStaticsModel
68
func searchUsers(searchCond: String?, page: Int, size: Int) async throws -> UserSearchResponse
79
func deleteMyProfileImage() async throws -> UserModel
810
func issuePresignedURL() async throws -> PresignedURLResponse
911
func uploadImageToPresignedURL(imageData: Data, presignedURL: URL) async throws
1012
func updateMyProfileImage(request: UpdateMyProfileImageRequest) async throws -> UserModel
13+
func updateMyUsername(username: String) async throws -> UserModel
1114
func updateMyTag(tag: String) async throws -> UserModel
1215
}
1316

@@ -70,6 +73,47 @@ struct UserService: UserServicing {
7073
}
7174
}
7275

76+
func fetchInitSettings() async throws -> UserInitSettingsResponse {
77+
do {
78+
let request = APIRequest(
79+
path: "/users/init-settings",
80+
method: .get,
81+
queryItems: [
82+
URLQueryItem(name: "clientVersion", value: Self.clientVersion),
83+
URLQueryItem(name: "clientType", value: Self.clientType)
84+
],
85+
requiresAuthorization: true
86+
)
87+
return try await apiClient.request(request, responseType: UserInitSettingsResponse.self)
88+
} catch {
89+
throw mapError(error)
90+
}
91+
}
92+
93+
func submitPolicyAgreement(agreements: [PolicyAgreementItem]) async throws {
94+
let requestBody: Data
95+
do {
96+
requestBody = try JSONEncoder().encode(PolicyAgreementRequest(agreements: agreements))
97+
} catch {
98+
throw UserServiceError.requestEncodingFailed
99+
}
100+
101+
do {
102+
var request = APIRequest(
103+
path: "/users/policy-agreement",
104+
method: .post,
105+
requiresAuthorization: true,
106+
body: requestBody
107+
)
108+
request.headers["Accept"] = "application/json"
109+
request.headers["Content-Type"] = "application/json"
110+
try await apiClient.request(request)
111+
} catch {
112+
if isRequestCancelled(error) { throw error }
113+
throw mapError(error)
114+
}
115+
}
116+
73117
func fetchUserStatics(userId: Int) async throws -> UserStaticsModel {
74118
do {
75119
let request = APIRequest(
@@ -221,6 +265,31 @@ struct UserService: UserServicing {
221265
}
222266
}
223267

268+
func updateMyUsername(username: String) async throws -> UserModel {
269+
let requestBody: Data
270+
do {
271+
requestBody = try JSONEncoder().encode(UpdateMyUsernameRequest(username: username))
272+
} catch {
273+
throw UserServiceError.requestEncodingFailed
274+
}
275+
276+
do {
277+
var request = APIRequest(
278+
path: "/users/my/names",
279+
method: .patch,
280+
requiresAuthorization: true,
281+
body: requestBody
282+
)
283+
request.headers["Accept"] = "application/json"
284+
request.headers["Content-Type"] = "application/json"
285+
let response = try await apiClient.request(request, responseType: UserResponseDTO.self)
286+
return response.toModel()
287+
} catch {
288+
if isRequestCancelled(error) { throw error }
289+
throw mapError(error)
290+
}
291+
}
292+
224293
private func mapError(_ error: Error) -> UserServiceError {
225294
if let userServiceError = error as? UserServiceError {
226295
return userServiceError
@@ -299,6 +368,17 @@ struct UserService: UserServicing {
299368
let nsError = error as NSError
300369
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled
301370
}
371+
372+
private static var clientVersion: String {
373+
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
374+
let buildVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
375+
let resolved = (shortVersion?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
376+
? shortVersion
377+
: buildVersion) ?? "0.0.0"
378+
return resolved
379+
}
380+
381+
private static let clientType = "IOS"
302382
}
303383

304384
private struct UserServiceErrorResponse: Decodable {

KillingPart/ViewModels/AppViewModel.swift

Lines changed: 152 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,68 @@ import Foundation
22

33
@MainActor
44
final class AppViewModel: ObservableObject {
5+
enum UpdatePrompt: Identifiable, Equatable {
6+
case force
7+
case optional
8+
9+
var id: String {
10+
switch self {
11+
case .force:
12+
return "force"
13+
case .optional:
14+
return "optional"
15+
}
16+
}
17+
18+
var title: String {
19+
switch self {
20+
case .force:
21+
return "업데이트 필요"
22+
case .optional:
23+
return "업데이트 권장"
24+
}
25+
}
26+
27+
var message: String {
28+
switch self {
29+
case .force:
30+
return "안정적인 서비스 이용을 위해 최신 버전으로 업데이트해 주세요."
31+
case .optional:
32+
return "새 버전이 출시되었어요. 지금 업데이트하면 더 나은 경험을 이용할 수 있어요."
33+
}
34+
}
35+
}
36+
537
@Published var currentStep: AppFlowStep = .splash
38+
@Published var updatePrompt: UpdatePrompt?
39+
@Published var setupFlowViewModel: InitialSetupFlowViewModel?
40+
@Published var isResolvingPostLoginFlow = false
641

742
let loginViewModel: LoginViewModel
843

944
private let tokenStore: TokenStoring
45+
private let userService: UserServicing
46+
private let diaryService: DiaryServicing
47+
private let calendarService: CalendarServicing
1048
private let notificationCenter: NotificationCenter
49+
private let appStoreURL: URL?
1150
private var sessionExpiredObserver: NSObjectProtocol?
1251

1352
init(
1453
authenticationService: AuthenticationServicing = AuthenticationService(),
1554
authService: AuthServicing = AuthService(),
55+
userService: UserServicing = UserService(),
56+
diaryService: DiaryServicing = DiaryService(),
57+
calendarService: CalendarServicing = CalendarService(),
1658
tokenStore: TokenStoring = TokenStore.shared,
1759
notificationCenter: NotificationCenter = .default
1860
) {
1961
self.tokenStore = tokenStore
62+
self.userService = userService
63+
self.diaryService = diaryService
64+
self.calendarService = calendarService
2065
self.notificationCenter = notificationCenter
66+
self.appStoreURL = Self.resolveAppStoreURL()
2167

2268
let loginViewModel = LoginViewModel(
2369
authenticationService: authenticationService,
@@ -26,7 +72,9 @@ final class AppViewModel: ObservableObject {
2672

2773
self.loginViewModel = loginViewModel
2874
self.loginViewModel.onLoginSuccess = { [weak self] _ in
29-
self?.currentStep = .main
75+
Task { @MainActor [weak self] in
76+
await self?.resolvePostLoginFlow()
77+
}
3078
}
3179

3280
sessionExpiredObserver = notificationCenter.addObserver(
@@ -47,15 +95,115 @@ final class AppViewModel: ObservableObject {
4795
}
4896

4997
func completeSplash() {
50-
currentStep = tokenStore.hasSessionTokens ? .main : .onboarding
98+
guard tokenStore.hasSessionTokens else {
99+
currentStep = .login
100+
return
101+
}
102+
103+
Task {
104+
await resolvePostLoginFlow()
105+
}
106+
}
107+
108+
func resolvePostLoginFlow() async {
109+
guard !isResolvingPostLoginFlow else { return }
110+
111+
isResolvingPostLoginFlow = true
112+
loginViewModel.loginErrorMessage = nil
113+
defer { isResolvingPostLoginFlow = false }
114+
115+
do {
116+
let settings = try await userService.fetchInitSettings()
117+
applyRoute(for: settings)
118+
119+
if settings.app.needsForceUpdate {
120+
updatePrompt = .force
121+
} else if settings.app.needsOptionalUpdate {
122+
updatePrompt = .optional
123+
}
124+
} catch {
125+
if isRequestCancelled(error) { return }
126+
currentStep = .login
127+
setupFlowViewModel = nil
128+
loginViewModel.loginErrorMessage = resolveErrorMessage(from: error)
129+
}
130+
}
131+
132+
func openAppStoreURLForUpdate() -> URL? {
133+
appStoreURL
51134
}
52135

53-
func completeOnboarding() {
54-
currentStep = tokenStore.hasSessionTokens ? .main : .login
136+
func dismissOptionalUpdatePrompt() {
137+
guard updatePrompt == .optional else { return }
138+
updatePrompt = nil
55139
}
56140

57141
func logout() {
58142
loginViewModel.resetState()
143+
setupFlowViewModel = nil
144+
updatePrompt = nil
59145
currentStep = .login
60146
}
147+
148+
private func applyRoute(for settings: UserInitSettingsResponse) {
149+
if settings.needsPolicyAgreement || settings.needsTagSetup {
150+
let setupViewModel = InitialSetupFlowViewModel(
151+
settings: settings,
152+
userService: userService,
153+
diaryService: diaryService,
154+
calendarService: calendarService
155+
)
156+
setupViewModel.onComplete = { [weak self] in
157+
self?.enterMainFlow()
158+
}
159+
setupFlowViewModel = setupViewModel
160+
currentStep = .setup
161+
return
162+
}
163+
164+
enterMainFlow()
165+
}
166+
167+
private func enterMainFlow() {
168+
setupFlowViewModel = nil
169+
currentStep = .main
170+
}
171+
172+
private func resolveErrorMessage(from error: Error) -> String {
173+
if let userServiceError = error as? UserServiceError {
174+
return userServiceError.errorDescription ?? "초기 설정 정보를 불러오지 못했어요."
175+
}
176+
177+
if let apiError = error as? APIClientError {
178+
return apiError.errorDescription ?? "초기 설정 정보를 불러오지 못했어요."
179+
}
180+
181+
if let localizedError = error as? LocalizedError {
182+
return localizedError.errorDescription ?? "초기 설정 정보를 불러오지 못했어요."
183+
}
184+
185+
return "초기 설정 정보를 불러오지 못했어요."
186+
}
187+
188+
private func isRequestCancelled(_ error: Error) -> Bool {
189+
if error is CancellationError { return true }
190+
191+
let nsError = error as NSError
192+
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled
193+
}
194+
195+
private static func resolveAppStoreURL() -> URL? {
196+
guard
197+
let rawValue = Bundle.main.object(forInfoDictionaryKey: "APP_STORE_URL") as? String
198+
else {
199+
return nil
200+
}
201+
202+
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
203+
guard !trimmed.isEmpty, let url = URL(string: trimmed) else {
204+
return nil
205+
}
206+
207+
return url
208+
}
61209
}

0 commit comments

Comments
 (0)