Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Application/App/Sources/App/DevLogApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct DevLogApp: App {
var body: some Scene {
WindowGroup {
RootView(
widgetURLTab: { MainTab(widgetURL: $0) },
widgetURLRoute: { WidgetRoute(widgetURL: $0) },
windowEvent: windowEvent,
pushNotificationTodoIdPublisher: PushNotificationRoute.shared.observe(),
clearPushNotificationRoute: { PushNotificationRoute.shared.clear() }
Expand Down
4 changes: 3 additions & 1 deletion Application/App/Sources/App/Graph/TodoGraphSet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ final class TodoGraphSet {
)
self.widgetTodoSnapshotRepositoryGraph = WidgetTodoSnapshotRepositoryGraph(
input: WidgetTodoSnapshotRepositoryGraphInput(
queryService: todoQueryServiceGraph.todoQueryService
queryService: todoQueryServiceGraph.todoQueryService,
todoCategoryService: todoCategoryServiceGraph.todoCategoryService,
store: memoryCacheStoreGraph.memoryCacheStore
)
)
self.todoUseCaseGraph = TodoUseCaseGraph(
Expand Down
25 changes: 0 additions & 25 deletions Application/App/Sources/App/Routing/MainTab+WidgetDeepLink.swift

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// WidgetRoute+WidgetDeepLink.swift
// DevLog
//
// Created by opfic on 9/24/26.
//

import Foundation
import Presentation
import WidgetCore

extension WidgetRoute {
init?(widgetURL: URL) {
guard let destination = WidgetDeepLink.destination(for: widgetURL) else { return nil }

switch destination {
case .today:
self = .tab(.today)
case .profile:
self = .tab(.profile)
case .todo(let id):
self = .todayTodo(id)
}
}
}
6 changes: 6 additions & 0 deletions Application/Core/Sources/WidgetTodoSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public struct WidgetTodoSnapshot: Equatable {
public let id: String
public let number: Int?
public let title: String
public let categoryID: String
public let categoryColorHex: String?
public let isPinned: Bool
public let createdAt: Date
public let completedAt: Date?
Expand All @@ -21,6 +23,8 @@ public struct WidgetTodoSnapshot: Equatable {
id: String,
number: Int?,
title: String,
categoryID: String,
categoryColorHex: String?,
isPinned: Bool,
createdAt: Date,
completedAt: Date?,
Expand All @@ -30,6 +34,8 @@ public struct WidgetTodoSnapshot: Equatable {
self.id = id
self.number = number
self.title = title
self.categoryID = categoryID
self.categoryColorHex = categoryColorHex
self.isPinned = isPinned
self.createdAt = createdAt
self.completedAt = completedAt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,28 @@ import Domain

public struct WidgetTodoSnapshotRepositoryGraphInput {
public let queryService: TodoQueryService
public let todoCategoryService: TodoCategoryService
public let store: MemoryCacheStore

public init(queryService: TodoQueryService) {
public init(
queryService: TodoQueryService,
todoCategoryService: TodoCategoryService,
store: MemoryCacheStore
) {
self.queryService = queryService
self.todoCategoryService = todoCategoryService
self.store = store
}
}

@DependencyGraph(input: WidgetTodoSnapshotRepositoryGraphInput.self)
public final class WidgetTodoSnapshotRepositoryGraph {
@Provide
private func makeWidgetTodoSnapshotRepository() -> WidgetTodoSnapshotRepository {
WidgetTodoSnapshotRepositoryImpl(queryService: input.queryService)
WidgetTodoSnapshotRepositoryImpl(
queryService: input.queryService,
todoCategoryService: input.todoCategoryService,
store: input.store
)
}
}
11 changes: 11 additions & 0 deletions Application/Data/Sources/Mapper/TodoMapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ public extension WidgetTodoSnapshot {
id: todo.id,
number: todo.number,
title: todo.title,
categoryID: todo.category.storageValue,
categoryColorHex: todo.category.widgetColorHex,
isPinned: todo.isPinned,
createdAt: todo.createdAt,
completedAt: todo.completedAt,
Expand All @@ -98,6 +100,8 @@ public extension WidgetTodoSnapshot {
id: draft.id,
number: nil,
title: draft.title,
categoryID: draft.category.storageValue,
categoryColorHex: draft.category.widgetColorHex,
isPinned: draft.isPinned,
createdAt: draft.createdAt,
completedAt: draft.completedAt,
Expand All @@ -107,6 +111,13 @@ public extension WidgetTodoSnapshot {
}
}

private extension TodoCategory {
var widgetColorHex: String? {
guard case .user(let category) = self else { return nil }
return category.colorHex
}
}

public extension TodoCursorDTO {
func toDomain() -> TodoCursor {
TodoCursor(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,22 @@ import Core
import Domain

final class WidgetTodoSnapshotRepositoryImpl: WidgetTodoSnapshotRepository {
private enum Key {
static let preferences = "TodoCategory.preferences"
}

private let queryService: TodoQueryService
private let todoCategoryService: TodoCategoryService
private let store: MemoryCacheStore

init(queryService: TodoQueryService) {
init(
queryService: TodoQueryService,
todoCategoryService: TodoCategoryService,
store: MemoryCacheStore
) {
self.queryService = queryService
self.todoCategoryService = todoCategoryService
self.store = store
}

func fetchTodayTodos(
Expand All @@ -32,8 +44,11 @@ final class WidgetTodoSnapshotRepositoryImpl: WidgetTodoSnapshotRepository {
)

do {
let todoPage = try await queryService.fetchTodos(query, cursor: nil)
return todoPage.items.map(WidgetTodoSnapshot.fromResponse)
async let todoPage = queryService.fetchTodos(query, cursor: nil)
async let preferences = categoryPreferences()
let (page, categoryPreferences) = try await (todoPage, preferences)
let colors = userCategoryColors(from: categoryPreferences)
return page.items.map { WidgetTodoSnapshot.fromResponse($0, userCategoryColors: colors) }
} catch {
throw error.toDomain()
}
Expand All @@ -56,19 +71,59 @@ final class WidgetTodoSnapshotRepositoryImpl: WidgetTodoSnapshotRepository {

do {
let todoPage = try await queryService.fetchTodos(query, cursor: nil)
return todoPage.items.map(WidgetTodoSnapshot.fromResponse)
return todoPage.items.map { WidgetTodoSnapshot.fromResponse($0, userCategoryColors: [:]) }
} catch {
throw error.toDomain()
}
}
}

private extension WidgetTodoSnapshotRepositoryImpl {
func categoryPreferences() async -> [TodoCategoryPreferenceResponse] {
if let preferences = store.value(forKey: Key.preferences) as [TodoCategoryPreferenceResponse]? {
return preferences
}

guard let preferences = try? await todoCategoryService.fetchCategoryPreferences() else {
return []
}
store.setValue(preferences, forKey: Key.preferences)
return preferences
}

func userCategoryColors(from preferences: [TodoCategoryPreferenceResponse]) -> [String: String] {
var colors = [String: String]()
for preference in preferences {
guard case .user(let category) = preference.category else { continue }
colors[category.id] = category.colorHex
}
return colors
}
}

private extension WidgetTodoSnapshot {
static func fromResponse(_ response: TodoResponse) -> Self {
WidgetTodoSnapshot(
static func fromResponse(_ response: TodoResponse, userCategoryColors: [String: String]) -> Self {
let categoryID: String
let categoryColorHex: String?
switch response.category {
case .raw(let id):
categoryID = id
categoryColorHex = userCategoryColors[id]
case .decoded(let category):
categoryID = category.storageValue
if case .user(let userCategory) = category {
categoryColorHex = userCategory.colorHex
} else {
categoryColorHex = nil
}
}

return WidgetTodoSnapshot(
id: response.id,
number: response.number,
title: response.title,
categoryID: categoryID,
categoryColorHex: categoryColorHex,
isPinned: response.isPinned,
createdAt: response.createdAt,
completedAt: response.completedAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ struct WidgetTodoSnapshotRepositoryImplTests {
@Test("Widget 오늘 Todo 조회는 Query service에만 전달한다")
func Widget_오늘_Todo_조회는_Query_service에만_전달한다() async throws {
let queryService = TodoRepositoryQueryServiceSpy()
let repository = WidgetTodoSnapshotRepositoryImpl(queryService: queryService)
let repository = WidgetTodoSnapshotRepositoryImpl(
queryService: queryService,
todoCategoryService: WidgetTodoCategoryServiceSpy(),
store: WidgetTodoMemoryCacheStoreSpy()
)

let snapshots = try await repository.fetchTodayTodos(
dueDateFilter: .withDueDate,
Expand All @@ -24,8 +28,84 @@ struct WidgetTodoSnapshotRepositoryImplTests {

let query = try #require(await queryService.fetchTodoQueries().first)
#expect(snapshots.map(\.id) == ["todo-1"])
#expect(snapshots.map(\.categoryID) == ["feature"])
#expect(query.completionFilter == .incomplete)
#expect(query.dueDateFilter == .withDueDate)
#expect(query.fetchAllPages)
}

@Test("Widget 오늘 Todo 스냅샷에 사용자 카테고리 색상을 전달한다")
func Widget_오늘_Todo_스냅샷에_사용자_카테고리_색상을_전달한다() async throws {
let repository = WidgetTodoSnapshotRepositoryImpl(
queryService: WidgetTodoQueryServiceSpy(),
todoCategoryService: WidgetTodoCategoryServiceSpy(
preferences: [
TodoCategoryPreferenceResponse(
category: .user(.init(id: "custom", name: "사용자", colorHex: "#AABBCC")),
isVisible: true
)
]
),
store: WidgetTodoMemoryCacheStoreSpy()
)

let snapshots = try await repository.fetchTodayTodos(
dueDateFilter: .withDueDate,
sortTarget: .dueDate,
sortOrder: .latest,
pageSize: 10
)

#expect(snapshots.first?.categoryID == "custom")
#expect(snapshots.first?.categoryColorHex == "#AABBCC")
}
}

private actor WidgetTodoQueryServiceSpy: TodoQueryService {
func fetchTodos(_ query: TodoQuery, cursor: TodoCursorDTO?) async throws -> TodoPageResponse {
let response = TodoResponse(
id: "todo-custom",
isPinned: false,
isCompleted: false,
isChecked: false,
number: 1,
title: "사용자 Todo",
content: "",
createdAt: .distantPast,
updatedAt: .distantPast,
completedAt: nil,
deletedAt: nil,
dueDate: nil,
tags: [],
category: .raw("custom")
)
return TodoPageResponse(items: [response], nextCursor: nil)
}

func fetchTodo(todoId: String) async throws -> TodoResponse {
throw DataLayerError.invalidData(todoId)
}

func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReferenceResponse] {
[:]
}
}

private actor WidgetTodoCategoryServiceSpy: TodoCategoryService {
let preferences: [TodoCategoryPreferenceResponse]

init(preferences: [TodoCategoryPreferenceResponse] = []) {
self.preferences = preferences
}

func fetchCategoryPreferences() async throws -> [TodoCategoryPreferenceResponse] {
preferences
}

func updateCategoryPreferences(_ preferences: [TodoCategoryPreferenceResponse]) async throws { }
}

private final class WidgetTodoMemoryCacheStoreSpy: MemoryCacheStore {
func value<T: Codable>(forKey key: String) -> T? { nil }
func setValue<T: Codable>(_ value: T?, forKey key: String) { }
}
Loading
Loading