Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b6fd228
feat: HomeFeature 1차 구현
opficdev Jun 14, 2026
1b32fdd
refactor: tca 요소로 애니메이션 처리
opficdev Jun 14, 2026
5f42ddc
refactor: 코디네이터가 Store을 들고있기 때문에 Bindable로 수정
opficdev Jun 14, 2026
1ad6e9f
refactor: CasePathable를 통해 SheetState 구현
opficdev Jun 14, 2026
27a2340
refactor: AlertState 적용
opficdev Jun 14, 2026
9f0f069
refactor: 웹페이지 url은 시트에서 처리하도록 개선
opficdev Jun 14, 2026
9a4564d
refactor: 웹페이지 입력은 얼럿에서 시트에서 내비게이션 형태로 수정
opficdev Jun 14, 2026
56be310
refactor: FullScreenCoverState 적용
opficdev Jun 14, 2026
849e624
ui: 상단 내비게이션바 영역 최소화
opficdev Jun 14, 2026
3d36014
refactor: 뷰에서 쓰는 순서대로 컴포넌트 정렬
opficdev Jun 14, 2026
241e690
ui: row 간의 패딩 최소화
opficdev Jun 14, 2026
e2ce200
refactor: 미사용 코드 제거
opficdev Jun 14, 2026
d275023
refactor: 웹페이지 액션을 ContentPicker 내로 이전
opficdev Jun 14, 2026
e981b02
refactor: ModalType 제거
opficdev Jun 14, 2026
77b2a57
test: HomeFeature에 맞도록 테스트코드 수정
opficdev Jun 14, 2026
a7ebb53
refactor: 하나의 액션만 send 하도록 수정
opficdev Jun 14, 2026
4607a37
refactor: 불필요 DispatchQueue.main.async 제거
opficdev Jun 14, 2026
53f3094
fix: Today fetchData 테스트 병렬 호출 검증 안정화
opficdev Jun 14, 2026
64f00d4
refactor: 피커 여는 액션 개선
opficdev Jun 14, 2026
f4f54dd
refactor: view / store 간 액션 분리
opficdev Jun 14, 2026
aad35e4
refactor: 이미 분리되어 있던 Store들의 reduce() 패턴 싱크
opficdev Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//
// HomeFeature+Dependencies.swift
// DevLogPresentation
//
// Created by opfic on 6/14/26.
//

import ComposableArchitecture
import DevLogDomain

extension DependencyValues {
var homeUpdateTodoCategoryPreferencesUseCase: UpdateTodoCategoryPreferencesUseCase {
get { self[HomeUpdatePreferencesUseCaseKey.self] }
set { self[HomeUpdatePreferencesUseCaseKey.self] = newValue }
}

var homeAddWebPageUseCase: AddWebPageUseCase {
get { self[HomeAddWebPageUseCaseKey.self] }
set { self[HomeAddWebPageUseCaseKey.self] = newValue }
}

var homeDeleteWebPageUseCase: DeleteWebPageUseCase {
get { self[HomeDeleteWebPageUseCaseKey.self] }
set { self[HomeDeleteWebPageUseCaseKey.self] = newValue }
}

var homeUndoDeleteWebPageUseCase: UndoDeleteWebPageUseCase {
get { self[HomeUndoDeleteWebPageUseCaseKey.self] }
set { self[HomeUndoDeleteWebPageUseCaseKey.self] = newValue }
}

var homeFetchTodosUseCase: FetchTodosUseCase {
get { self[HomeFetchTodosUseCaseKey.self] }
set { self[HomeFetchTodosUseCaseKey.self] = newValue }
}

var homeFetchWebPagesUseCase: FetchWebPagesUseCase {
get { self[HomeFetchWebPagesUseCaseKey.self] }
set { self[HomeFetchWebPagesUseCaseKey.self] = newValue }
}
}

private enum HomeUpdatePreferencesUseCaseKey: DependencyKey {
static var liveValue: UpdateTodoCategoryPreferencesUseCase {
preconditionFailure("UpdateTodoCategoryPreferencesUseCase must be provided.")
}

static var testValue: UpdateTodoCategoryPreferencesUseCase {
liveValue
}
}

private enum HomeAddWebPageUseCaseKey: DependencyKey {
static var liveValue: AddWebPageUseCase {
preconditionFailure("AddWebPageUseCase must be provided.")
}

static var testValue: AddWebPageUseCase {
liveValue
}
}

private enum HomeDeleteWebPageUseCaseKey: DependencyKey {
static var liveValue: DeleteWebPageUseCase {
preconditionFailure("DeleteWebPageUseCase must be provided.")
}

static var testValue: DeleteWebPageUseCase {
liveValue
}
}

private enum HomeUndoDeleteWebPageUseCaseKey: DependencyKey {
static var liveValue: UndoDeleteWebPageUseCase {
preconditionFailure("UndoDeleteWebPageUseCase must be provided.")
}

static var testValue: UndoDeleteWebPageUseCase {
liveValue
}
}

private enum HomeFetchTodosUseCaseKey: DependencyKey {
static var liveValue: FetchTodosUseCase {
preconditionFailure("FetchTodosUseCase must be provided.")
}

static var testValue: FetchTodosUseCase {
liveValue
}
}

private enum HomeFetchWebPagesUseCaseKey: DependencyKey {
static var liveValue: FetchWebPagesUseCase {
preconditionFailure("FetchWebPagesUseCase must be provided.")
}

static var testValue: FetchWebPagesUseCase {
liveValue
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
//
// HomeFeature+Effects.swift
// DevLogPresentation
//
// Created by opfic on 6/14/26.
//

import Combine
import ComposableArchitecture
import DevLogCore
import DevLogDomain
import Foundation

extension HomeFeature {
private enum CancelID: Hashable {
case delayedTodoEditor
case networkConnectivity
}

func observeNetworkConnectivityEffect() -> Effect<Action> {
.publisher { [networkConnectivityUseCase] in
networkConnectivityUseCase.observe()
.receive(on: DispatchQueue.main)
.map { .store(.networkStatusChanged($0)) }
}
.cancellable(id: CancelID.networkConnectivity, cancelInFlight: true)
}

func fetchTodoCategoryPreferencesEffect() -> Effect<Action> {
.run { [fetchPreferencesUseCase] send in
await send(.loading(.begin(target: LoadingTarget.preferences.target, mode: .immediate)))
do {
let preferences = try await fetchPreferencesUseCase.execute()
await send(.store(.setTodoCategory(preferences.map(TodoCategoryItem.init(from:)))))
} catch {
await send(.store(.setAlert(isPresented: true, type: .error)))
}
await send(.loading(.end(target: LoadingTarget.preferences.target, mode: .immediate)))
}
}

func fetchRecentTodosEffect() -> Effect<Action> {
.run { [fetchTodosUseCase] send in
await send(.loading(.begin(target: LoadingTarget.recentTodos.target, mode: .immediate)))
do {
let page = try await fetchRecentTodos(fetchTodosUseCase: fetchTodosUseCase)
let items = page.items
.filter { $0.createdAt != $0.updatedAt }
.prefix(5)
.compactMap(RecentTodoItem.init(from:))
await send(.store(.updateRecentTodos(Array(items))))
} catch {
await send(.store(.setAlert(isPresented: true, type: .error)))
}
await send(.loading(.end(target: LoadingTarget.recentTodos.target, mode: .immediate)))
}
}

func fetchWebPagesEffect() -> Effect<Action> {
.run { [fetchWebPagesUseCase] send in
await send(.loading(.begin(target: LoadingTarget.webPage.target, mode: .immediate)))
do {
let pages = try await fetchWebPagesUseCase.execute("")
await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:)))))
} catch {
await send(.store(.setAlert(isPresented: true, type: .error)))
}
await send(.loading(.end(target: LoadingTarget.webPage.target, mode: .immediate)))
}
}

func addWebPageEffect(_ urlString: String) -> Effect<Action> {
.run { [addWebPageUseCase, fetchWebPagesUseCase, trackAnalyticsEventUseCase] send in
await send(.loading(.begin(target: LoadingTarget.overlay.target, mode: .delayed)))
do {
try await addWebPageUseCase.execute(urlString)
trackAnalyticsEventUseCase?.execute(.webPageCreate)
let pages = try await fetchWebPagesUseCase.execute("")
await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:)))))
} catch {
await send(.store(.setAlert(isPresented: true, type: .error)))
}
await send(.loading(.end(target: LoadingTarget.overlay.target, mode: .delayed)))
}
}

func deleteWebPageEffect(_ page: WebPageItem) -> Effect<Action> {
.run { [deleteWebPageUseCase] send in
do {
try await deleteWebPageUseCase.execute(page.url.absoluteString)
} catch {
await send(.store(.handleWebPageDeleteFailure(page.id)))
await send(.store(.setAlert(isPresented: true, type: .error)))
}
}
}

func undoDeleteWebPageEffect(_ urlString: String) -> Effect<Action> {
.run { [undoDeleteWebPageUseCase, addWebPageUseCase] send in
do {
try await undoDeleteWebPageUseCase.execute(urlString)
try await addWebPageUseCase.execute(urlString)
} catch {
if let webPageURL = URL(string: urlString) {
await send(.store(.setWebPageHidden(webPageURL, true)))
}
await send(.store(.setAlert(isPresented: true, type: .error)))
}
}
}

func updateTodoCategoryPreferencesEffect(_ items: [TodoCategoryItem]) -> Effect<Action> {
.run { [updatePreferencesUseCase] send in
do {
try await updatePreferencesUseCase.execute(items.map(\.preference))
} catch {
await send(.store(.setAlert(isPresented: true, type: .error)))
}
}
}

func delayedTodoEditorEffect() -> Effect<Action> {
.run { [clock] send in
// iOS 17에서 시트 dismiss 직후 fullScreenCover를 바로 올리지 않도록 하기 위해서 0.1초 딜레이
try await clock.sleep(for: .seconds(0.1))
await send(.store(.setPresentation(.todoEditor, true)))
}
.cancellable(id: CancelID.delayedTodoEditor, cancelInFlight: true)
}

func fetchRecentTodos(fetchTodosUseCase: FetchTodosUseCase) async throws -> TodoPage {
try await fetchTodosUseCase.execute(
TodoQuery(
sortTarget: .updatedAt,
sortOrder: .latest,
pageSize: 100
),
cursor: nil
)
}

static func setPresentation(
_ state: inout State,
presentation: Presentation,
isPresented: Bool
) {
switch presentation {
case .todoEditor:
state.fullScreenCover = isPresented ? state.selectedTodoCategory.map(FullScreenCoverState.todoEditor) : nil
if !isPresented {
state.selectedTodoCategory = nil
}
case .contentPicker:
state.sheet = isPresented ? .contentPicker(.init()) : state.showContentPicker ? nil : state.sheet
case .searchView:
state.fullScreenCover = isPresented ? .search : nil
}
}

static func setAlert(
_ state: inout State,
isPresented: Bool,
type: AlertType?
) {
guard isPresented, let type else {
state.alert = nil
return
}

state.alert = alertState(for: type)
}

static func alertState(for type: AlertType) -> AlertState<Never> {
let title: String
let message: String

switch type {
case .invalidURL:
title = String(localized: "home_invalid_url_title")
message = String(localized: "home_invalid_url_message")
case .error:
title = String(localized: "common_error_title")
message = String(localized: "common_error_message")
}

return AlertState<Never> {
TextState(title)
} actions: {
ButtonState(role: .cancel) {
TextState(String(localized: "common_close"))
}
} message: {
TextState(message)
}
}

static func syncRecentTodos(
_ recentTodos: [RecentTodoItem],
preferences: [TodoCategoryItem]
) -> [RecentTodoItem] {
recentTodos.map { recentTodo in
guard let item = preferences.first(where: {
$0.category.storageValue == recentTodo.category.storageValue
}) else {
return recentTodo
}

var recentTodo = recentTodo
recentTodo.category = item.category
return recentTodo
}
}

static 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
}
}
Loading
Loading