-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathHomeViewModel.swift
More file actions
435 lines (405 loc) · 14.9 KB
/
Copy pathHomeViewModel.swift
File metadata and controls
435 lines (405 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
//
// HomeViewModel.swift
// DevLog
//
// Created by 최윤진 on 11/22/25.
//
import Foundation
@Observable
final class HomeViewModel: Store {
struct State: Equatable {
var todoCategoryPreferences = TodoCategory.allCases.map {
TodoCategoryPreference(category: $0, isVisible: true)
}
var recentTodos: [RecentTodoItem] = []
var webPages: [WebPageItem] = []
var showContentPicker: Bool = false
var showTodoEditor: Bool = false
var showSearchView: Bool = false
var webPageURLInput: String = "https://"
var selectedTodoCategory: TodoCategory?
var reorderTodo: Bool = false
var isRecentTodosLoading: Bool = false
var isWebPageLoading: Bool = false
var isAppending: Bool = false
var showAlert: Bool = false
var alertTitle: String = ""
var alertType: AlertType?
var alertMessage: String = ""
var showToast: Bool = false
var toastType: ToastType?
var toastMessage: String = ""
}
enum Action {
case onAppear
case setPresentation(Presentation, Bool)
case setAlert(isPresented: Bool, type: AlertType? = nil)
case setToast(isPresented: Bool, type: ToastType? = nil)
case setLoading(LoadingTarget, Bool)
case tapTodoCategory(TodoCategory)
case orderTodoCategoryPreferences([TodoCategoryPreference])
case addTodo(Todo)
case updateRecentTodos([RecentTodoItem])
case updateWebPageURLInput(String)
case addWebPage
case deleteWebPage(WebPageItem)
case undoDeleteWebPage
case updateWebPages([WebPageItem])
case restoreWebPage(WebPageItem, Int)
}
enum SideEffect {
case addTodo(Todo)
case addWebPage(String)
case deleteWebPage(WebPageItem, Int)
case undoDeleteWebPage(String)
case fetchRecentTodos
case fetchWebPages
case showModalAfterDelay(ModalType)
}
enum AlertType {
case webPageInput
case invalidURL
case error
}
enum ToastType {
case deleteWebPage
}
enum ModalType {
case todoEditor
case urlInputAlert
}
enum Presentation {
case reorderTodo
case todoEditor
case contentPicker
case searchView
}
enum LoadingTarget: Hashable {
case recentTodos
case webPage
case overlay
}
private(set) var state = State()
private let upsertTodoUseCase: UpsertTodoUseCase
private let addWebPageUseCase: AddWebPageUseCase
private let deleteWebPageUseCase: DeleteWebPageUseCase
private let undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase
private let fetchTodosUseCase: FetchTodosUseCase
private let fetchWebPagesUseCase: FetchWebPagesUseCase
private let loadingState = LoadingState()
private var deletedWebPageURLString: String?
init(
addWebPageUseCase: AddWebPageUseCase,
deleteWebPageUseCase: DeleteWebPageUseCase,
undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase,
upsertTodoUseCase: UpsertTodoUseCase,
fetchTodosUseCase: FetchTodosUseCase,
fetchWebPagesUseCase: FetchWebPagesUseCase
) {
self.addWebPageUseCase = addWebPageUseCase
self.deleteWebPageUseCase = deleteWebPageUseCase
self.undoDeleteWebPageUseCase = undoDeleteWebPageUseCase
self.upsertTodoUseCase = upsertTodoUseCase
self.fetchTodosUseCase = fetchTodosUseCase
self.fetchWebPagesUseCase = fetchWebPagesUseCase
}
func reduce(with action: Action) -> [SideEffect] {
var state = self.state
var effects: [SideEffect] = []
switch action {
case .onAppear, .setPresentation, .setAlert, .setToast, .tapTodoCategory,
.orderTodoCategoryPreferences, .addTodo, .updateWebPageURLInput,
.addWebPage, .deleteWebPage, .undoDeleteWebPage:
effects = reduceByView(action, state: &state)
case .setLoading, .updateRecentTodos, .updateWebPages, .restoreWebPage:
effects = reduceByRun(action, state: &state)
}
if self.state != state { self.state = state }
return effects
}
func run(_ effect: SideEffect) {
switch effect {
case .addTodo(let todo):
beginLoading(for: .overlay, mode: .delayed)
Task {
do {
defer { endLoading(for: .overlay, mode: .delayed) }
try await upsertTodoUseCase.execute(todo)
let page = try await fetchRecentTodos()
let items = page.items
.filter { $0.createdAt != $0.updatedAt }
.prefix(5)
.map { RecentTodoItem(from: $0) }
send(.updateRecentTodos(items))
} catch {
send(.setAlert(isPresented: true, type: .error))
}
}
case .fetchRecentTodos:
beginLoading(for: .recentTodos, mode: .immediate)
Task {
do {
defer { endLoading(for: .recentTodos, mode: .immediate) }
let page = try await fetchRecentTodos()
let items = page.items
.filter { $0.createdAt != $0.updatedAt }
.prefix(5)
.map { RecentTodoItem(from: $0) }
send(.updateRecentTodos(items))
} catch {
send(.setAlert(isPresented: true, type: .error))
}
}
case .addWebPage(let urlString):
beginLoading(for: .overlay, mode: .delayed)
Task {
do {
defer { endLoading(for: .overlay, mode: .delayed) }
try await addWebPageUseCase.execute(urlString)
let pages = try await fetchWebPagesUseCase.execute("")
send(.updateWebPages(pages.map { WebPageItem(from: $0) }))
} catch {
send(.setAlert(isPresented: true, type: .error))
}
}
case .deleteWebPage(let page, let index):
beginLoading(for: .webPage, mode: .delayed)
Task {
do {
defer { endLoading(for: .webPage, mode: .delayed) }
try await deleteWebPageUseCase.execute(page.url.absoluteString)
} catch {
send(.restoreWebPage(page, index))
send(.setAlert(isPresented: true, type: .error))
}
}
case .undoDeleteWebPage(let urlString):
beginLoading(for: .webPage, mode: .delayed)
Task {
defer { endLoading(for: .webPage, mode: .delayed) }
var shouldPresentError = false
do {
try await undoDeleteWebPageUseCase.execute(urlString)
} catch {
shouldPresentError = true
}
do {
let pages = try await fetchWebPagesUseCase.execute("")
send(.updateWebPages(pages.map { WebPageItem(from: $0) }))
} catch {
shouldPresentError = true
}
if shouldPresentError {
send(.setAlert(isPresented: true, type: .error))
}
}
case .fetchWebPages:
beginLoading(for: .webPage, mode: .immediate)
Task {
do {
defer { endLoading(for: .webPage, mode: .immediate) }
let pages = try await fetchWebPagesUseCase.execute("")
send(.updateWebPages(pages.map { WebPageItem(from: $0) }))
} catch {
send(.setAlert(isPresented: true, type: .error))
}
}
case .showModalAfterDelay(let type):
Task {
try await Task.sleep(for: .seconds(0.1))
switch type {
case .todoEditor:
send(.setPresentation(.todoEditor, true))
case .urlInputAlert:
send(.setAlert(isPresented: true, type: .webPageInput))
}
}
}
}
}
// MARK: - Reduce Methods
private extension HomeViewModel {
// swiftlint:disable cyclomatic_complexity
func reduceByView(_ action: Action, state: inout State) -> [SideEffect] {
switch action {
case .onAppear:
return [.fetchRecentTodos, .fetchWebPages]
case .setPresentation(let presentation, let isPresented):
setPresentation(&state, presentation: presentation, isPresented: isPresented)
case .setAlert(let presented, let type):
if presented && type == .webPageInput && state.showContentPicker {
state.showContentPicker = false
return [.showModalAfterDelay(.urlInputAlert)]
}
setAlert(&state, isPresented: presented, type: type)
case .setToast(let isPresented, let type):
setToast(&state, isPresented: isPresented, for: type)
if !isPresented {
deletedWebPageURLString = nil
}
case .tapTodoCategory(let category):
state.selectedTodoCategory = category
state.showContentPicker = false
return [.showModalAfterDelay(.todoEditor)]
case .orderTodoCategoryPreferences(let preferences):
state.todoCategoryPreferences = preferences
case .addTodo(let todo):
return [.addTodo(todo)]
case .updateWebPageURLInput(let text):
state.webPageURLInput = text
case .addWebPage:
guard let normalizedURL = normalizedWebPageURL(state.webPageURLInput) else {
setAlert(&state, isPresented: true, type: .invalidURL)
return []
}
setAlert(&state, isPresented: false, type: nil)
return [.addWebPage(normalizedURL)]
case .deleteWebPage(let page):
if let index = state.webPages.firstIndex(where: { $0.id == page.id }) {
deletedWebPageURLString = page.url.absoluteString
state.webPages.remove(at: index)
setToast(&state, isPresented: true, for: .deleteWebPage)
return [.deleteWebPage(page, index)]
}
case .undoDeleteWebPage:
guard let deletedWebPageURLString else { return [] }
self.deletedWebPageURLString = nil
return [.undoDeleteWebPage(deletedWebPageURLString)]
default:
break
}
return []
}
// swiftlint:enable cyclomatic_complexity
func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] {
switch action {
case .setLoading(let loadingTarget, let isLoading):
setLoading(&state, loadingTarget: loadingTarget, isLoading: isLoading)
case .updateRecentTodos(let todos):
state.recentTodos = todos
case .updateWebPages(let pages):
state.webPages = pages
case .restoreWebPage(let page, let index):
if state.webPages.contains(where: { $0.id == page.id }) { break }
if index <= state.webPages.count {
state.webPages.insert(page, at: index)
} else {
state.webPages.append(page)
}
if deletedWebPageURLString == page.url.absoluteString {
deletedWebPageURLString = nil
}
default:
break
}
return []
}
}
// MARK: - Helper Methods
private extension HomeViewModel {
func setPresentation(
_ state: inout State,
presentation: Presentation,
isPresented: Bool
) {
switch presentation {
case .reorderTodo:
state.reorderTodo = isPresented
case .todoEditor:
state.showTodoEditor = isPresented
if !isPresented { state.selectedTodoCategory = nil }
case .contentPicker:
state.showContentPicker = isPresented
case .searchView:
state.showSearchView = isPresented
}
}
func setAlert(
_ state: inout State,
isPresented: Bool,
type: AlertType?
) {
switch type {
case .webPageInput:
state.alertTitle = "URL 추가"
state.alertMessage = "웹페이지 URL을 입력해주세요."
state.webPageURLInput = "https://"
case .invalidURL:
state.alertTitle = "URL 확인"
state.alertMessage = "올바른 URL을 입력해주세요."
case .error:
state.alertTitle = "오류"
state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요."
case .none:
state.alertTitle = ""
state.alertMessage = ""
}
state.showAlert = isPresented
state.alertType = type
}
func setToast(
_ state: inout State,
isPresented: Bool,
for type: ToastType?
) {
switch type {
case .deleteWebPage:
state.toastMessage = "실행 취소"
case .none:
state.toastMessage = ""
}
state.showToast = isPresented
state.toastType = type
}
func setLoading(
_ state: inout State,
loadingTarget: LoadingTarget,
isLoading: Bool
) {
switch loadingTarget {
case .recentTodos:
state.isRecentTodosLoading = isLoading
case .webPage:
state.isWebPageLoading = isLoading
case .overlay:
state.isAppending = isLoading
}
}
func normalizedWebPageURL(_ input: String) -> String? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed == "https://" || trimmed == "http://" {
return nil
}
if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
return trimmed
}
return "https://" + trimmed
}
func fetchRecentTodos() async throws -> TodoPage {
try await fetchTodosUseCase.execute(
TodoQuery(
sortTarget: .updatedAt,
sortOrder: .latest,
pageSize: 100
),
cursor: nil
)
}
private func beginLoading(
for target: LoadingTarget,
mode: LoadingState.Mode
) {
loadingState.begin(target: target, mode: mode) { [weak self] target, isLoading in
self?.send(.setLoading(target, isLoading))
}
}
private func endLoading(
for target: LoadingTarget,
mode: LoadingState.Mode
) {
loadingState.end(target: target, mode: mode) { [weak self] target, isLoading in
self?.send(.setLoading(target, isLoading))
}
}
}