diff --git a/Application/App/Sources/App/DevLogApp.swift b/Application/App/Sources/App/DevLogApp.swift index 4cc23eae..27c8dff4 100644 --- a/Application/App/Sources/App/DevLogApp.swift +++ b/Application/App/Sources/App/DevLogApp.swift @@ -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() } diff --git a/Application/App/Sources/App/Graph/TodoGraphSet.swift b/Application/App/Sources/App/Graph/TodoGraphSet.swift index abd92b31..55602075 100644 --- a/Application/App/Sources/App/Graph/TodoGraphSet.swift +++ b/Application/App/Sources/App/Graph/TodoGraphSet.swift @@ -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( diff --git a/Application/App/Sources/App/Routing/MainTab+WidgetDeepLink.swift b/Application/App/Sources/App/Routing/MainTab+WidgetDeepLink.swift deleted file mode 100644 index ab7cf8a1..00000000 --- a/Application/App/Sources/App/Routing/MainTab+WidgetDeepLink.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// MainTab+WidgetDeepLink.swift -// DevLog -// -// Created by opfic on 5/15/26. -// - -import Foundation -import Presentation -import WidgetCore - -extension MainTab { - init?(widgetURL: URL) { - guard widgetURL.scheme?.lowercased() == WidgetDeepLink.scheme.lowercased() else { return nil } - - switch widgetURL.host { - case WidgetDeepLink.todayTodoHost: - self = .today - case WidgetDeepLink.heatmapHost: - self = .profile - default: - return nil - } - } -} diff --git a/Application/App/Sources/App/Routing/WidgetRoute+WidgetDeepLink.swift b/Application/App/Sources/App/Routing/WidgetRoute+WidgetDeepLink.swift new file mode 100644 index 00000000..0451e054 --- /dev/null +++ b/Application/App/Sources/App/Routing/WidgetRoute+WidgetDeepLink.swift @@ -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) + } + } +} diff --git a/Application/Core/Sources/WidgetTodoSnapshot.swift b/Application/Core/Sources/WidgetTodoSnapshot.swift index f292a2bc..5e10508e 100644 --- a/Application/Core/Sources/WidgetTodoSnapshot.swift +++ b/Application/Core/Sources/WidgetTodoSnapshot.swift @@ -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? @@ -21,6 +23,8 @@ public struct WidgetTodoSnapshot: Equatable { id: String, number: Int?, title: String, + categoryID: String, + categoryColorHex: String?, isPinned: Bool, createdAt: Date, completedAt: Date?, @@ -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 diff --git a/Application/Data/Sources/Graph/WidgetTodoSnapshotRepositoryGraph.swift b/Application/Data/Sources/Graph/WidgetTodoSnapshotRepositoryGraph.swift index 07c0735a..908ad889 100644 --- a/Application/Data/Sources/Graph/WidgetTodoSnapshotRepositoryGraph.swift +++ b/Application/Data/Sources/Graph/WidgetTodoSnapshotRepositoryGraph.swift @@ -10,9 +10,17 @@ 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 } } @@ -20,6 +28,10 @@ public struct WidgetTodoSnapshotRepositoryGraphInput { public final class WidgetTodoSnapshotRepositoryGraph { @Provide private func makeWidgetTodoSnapshotRepository() -> WidgetTodoSnapshotRepository { - WidgetTodoSnapshotRepositoryImpl(queryService: input.queryService) + WidgetTodoSnapshotRepositoryImpl( + queryService: input.queryService, + todoCategoryService: input.todoCategoryService, + store: input.store + ) } } diff --git a/Application/Data/Sources/Mapper/TodoMapping.swift b/Application/Data/Sources/Mapper/TodoMapping.swift index fdabcbfa..0fb766fc 100644 --- a/Application/Data/Sources/Mapper/TodoMapping.swift +++ b/Application/Data/Sources/Mapper/TodoMapping.swift @@ -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, @@ -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, @@ -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( diff --git a/Application/Data/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift b/Application/Data/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift index 66a65b20..ded65d48 100644 --- a/Application/Data/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift @@ -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( @@ -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() } @@ -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, diff --git a/Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift b/Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift index 4d10b94f..526aa777 100644 --- a/Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift @@ -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, @@ -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(forKey key: String) -> T? { nil } + func setValue(_ value: T?, forKey key: String) { } } diff --git a/Application/Presentation/Entry/Sources/Root/Feature.swift b/Application/Presentation/Entry/Sources/Root/Feature.swift index 9a57f614..0f7cf837 100644 --- a/Application/Presentation/Entry/Sources/Root/Feature.swift +++ b/Application/Presentation/Entry/Sources/Root/Feature.swift @@ -29,6 +29,7 @@ struct Feature { var signIn: Bool? var theme: SystemTheme = .automatic var selectedMainTab = MainTab.home + var widgetRoute: WidgetRoute? var isObservingNetworkConnectivity = false var isObservingSession = false var isObservingTheme = false @@ -47,7 +48,7 @@ struct Feature { case sheet(PresentationAction) case onAppear case presentTodoDetail(String) - case openWidgetRoute(MainTab) + case openWidgetRoute(WidgetRoute) case networkStatusChanged(Bool) case setTheme(SystemTheme) case didLogined(Bool) @@ -114,9 +115,10 @@ struct Feature { return effect case .presentTodoDetail(let todoId): state.sheet = .init(todoId: todoId) - case .openWidgetRoute(let mainTab): + case .openWidgetRoute(let route): + state.widgetRoute = route guard state.signIn == true else { break } - state.selectedMainTab = mainTab + Self.applyWidgetRoute(route, to: &state) case .networkStatusChanged(let isConnected): let wasConnected = state.isNetworkConnected state.isNetworkConnected = isConnected @@ -126,10 +128,18 @@ struct Feature { case .setTheme(let theme): state.theme = theme case .didLogined(let result): + let wasSignedIn = state.signIn == true state.signIn = result if result { - state.selectedMainTab = .home + if !wasSignedIn, let route = state.widgetRoute { + Self.applyWidgetRoute(route, to: &state) + } else { + state.selectedMainTab = .home + } } else { + if wasSignedIn { + state.widgetRoute = nil + } return .merge( trackLoginScreenEffect(), clearApplicationBadgeCountEffect() @@ -144,6 +154,15 @@ struct Feature { SheetFeature() } } + + private static func applyWidgetRoute(_ route: WidgetRoute, to state: inout State) { + switch route { + case .tab(let tab): + state.selectedMainTab = tab + case .todayTodo(let id): + state.sheet = .init(todoId: id) + } + } } private struct SheetFeature: Reducer { diff --git a/Application/Presentation/Entry/Sources/Root/RootView.swift b/Application/Presentation/Entry/Sources/Root/RootView.swift index 1c239d70..bb4e709a 100644 --- a/Application/Presentation/Entry/Sources/Root/RootView.swift +++ b/Application/Presentation/Entry/Sources/Root/RootView.swift @@ -11,13 +11,13 @@ import PresentationShared public struct RootView: View { @State private var store: StoreOf - private let widgetURLTab: (URL) -> MainTab? + private let widgetURLRoute: (URL) -> WidgetRoute? private let windowEvent: TodoEditorWindowEvent private let pushNotificationTodoIdPublisher: AnyPublisher private let clearPushNotificationRoute: () -> Void public init( - widgetURLTab: @escaping (URL) -> MainTab?, + widgetURLRoute: @escaping (URL) -> WidgetRoute?, windowEvent: TodoEditorWindowEvent, pushNotificationTodoIdPublisher: AnyPublisher, clearPushNotificationRoute: @escaping () -> Void @@ -25,7 +25,7 @@ public struct RootView: View { self._store = State(initialValue: Store(initialState: Feature.State()) { Feature() }) - self.widgetURLTab = widgetURLTab + self.widgetURLRoute = widgetURLRoute self.windowEvent = windowEvent self.pushNotificationTodoIdPublisher = pushNotificationTodoIdPublisher self.clearPushNotificationRoute = clearPushNotificationRoute @@ -48,8 +48,8 @@ public struct RootView: View { .preferredColorScheme(store.theme.colorScheme) .onAppear { store.send(.onAppear) } .onOpenURL { url in - guard let mainTab = widgetURLTab(url) else { return } - store.send(.openWidgetRoute(mainTab)) + guard let route = widgetURLRoute(url) else { return } + store.send(.openWidgetRoute(route)) } .prominentAlert(store, state: \.alert, action: \.alert) .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in diff --git a/Application/Presentation/Entry/Sources/Routing/WidgetRoute.swift b/Application/Presentation/Entry/Sources/Routing/WidgetRoute.swift new file mode 100644 index 00000000..ba4f714b --- /dev/null +++ b/Application/Presentation/Entry/Sources/Routing/WidgetRoute.swift @@ -0,0 +1,11 @@ +// +// WidgetRoute.swift +// Entry +// +// Created by opfic on 9/24/26. +// + +public enum WidgetRoute: Equatable { + case tab(MainTab) + case todayTodo(String) +} diff --git a/Application/Presentation/Entry/Tests/Root/FeatureTestSupport.swift b/Application/Presentation/Entry/Tests/Root/FeatureTestSupport.swift index 861ac674..a0a9d2c9 100644 --- a/Application/Presentation/Entry/Tests/Root/FeatureTestSupport.swift +++ b/Application/Presentation/Entry/Tests/Root/FeatureTestSupport.swift @@ -17,6 +17,7 @@ import Testing protocol RootStateDriving { var snapshot: RootStateSnapshot { get } var sheetTodoId: String? { get } + var widgetRoute: WidgetRoute? { get } func onAppear() async func setAlert(_ isPresented: Bool) async @@ -26,7 +27,7 @@ protocol RootStateDriving { func presentTodoDetail(_ todoId: String) async func dismissSheet() async func selectMainTab(_ tab: MainTab) async - func openWidgetRoute(_ tab: MainTab) async + func openWidgetRoute(_ route: WidgetRoute) async func tapUpdateButton() async } @@ -54,6 +55,7 @@ struct RootStoreTestAdapter: RootStateDriving { ) } var sheetTodoId: String? { store.state.sheet?.todoId } + var widgetRoute: WidgetRoute? { store.state.widgetRoute } init( sessionUseCase: ObserveAuthSessionUseCase = ObserveAuthSessionUseCaseSpy(currentValue: true), @@ -126,8 +128,8 @@ struct RootStoreTestAdapter: RootStateDriving { await store.send(.binding(.set(\.selectedMainTab, tab))) } - func openWidgetRoute(_ tab: MainTab) async { - await store.send(.openWidgetRoute(tab)) + func openWidgetRoute(_ route: WidgetRoute) async { + await store.send(.openWidgetRoute(route)) } func tapUpdateButton() async { @@ -261,12 +263,19 @@ func verifyTodoDetailSheetPresentation(adapter: some RootStateDriving) async { @MainActor func verifyWidgetRouteOpensWhenSignedIn(adapter: some RootStateDriving) async { - await adapter.openWidgetRoute(.today) + await adapter.openWidgetRoute(.tab(.today)) #expect(adapter.snapshot.selectedMainTab == .home) + #expect(adapter.widgetRoute == .tab(.today)) await adapter.didLogined(true) - await adapter.openWidgetRoute(.today) #expect(adapter.snapshot.selectedMainTab == .today) + #expect(adapter.widgetRoute == .tab(.today)) + #expect(adapter.sheetTodoId == nil) + + await adapter.presentTodoDetail("push-todo") + await adapter.openWidgetRoute(.todayTodo("todo-1")) + #expect(adapter.snapshot.selectedMainTab == .today) + #expect(adapter.sheetTodoId == "todo-1") } final class ObserveAuthSessionUseCaseSpy: ObserveAuthSessionUseCase { diff --git a/Application/Presentation/Entry/Tests/Root/FeatureTests.swift b/Application/Presentation/Entry/Tests/Root/FeatureTests.swift index 8ed25c58..44ce4fdb 100644 --- a/Application/Presentation/Entry/Tests/Root/FeatureTests.swift +++ b/Application/Presentation/Entry/Tests/Root/FeatureTests.swift @@ -180,12 +180,65 @@ struct FeatureTests { await verifyTodoDetailSheetPresentation(adapter: adapter) } - @Test("RootFeature는 로그인된 경우에만 widget route로 selectedMainTab을 변경한다") - func RootFeature는_로그인된_경우에만_widget_route로_selectedMainTab을_변경한다() async { + @Test("RootFeature는 로그인 확인 전 위젯 경로를 보관하고 확인 뒤 적용한다") + func RootFeature는_로그인_확인_전_위젯_경로를_보관하고_확인_뒤_적용한다() async { let adapter = RootStoreTestAdapter() await verifyWidgetRouteOpensWhenSignedIn(adapter: adapter) } + + @Test("RootFeature는 시작 전 들어온 위젯 Todo 경로를 현재 탭의 시트로 연다") + func RootFeature는_시작_전_들어온_위젯_Todo_경로를_현재_탭의_시트로_연다() async { + let adapter = RootStoreTestAdapter() + + await adapter.didLogined(false) + await adapter.openWidgetRoute(.todayTodo("todo-1")) + #expect(adapter.widgetRoute == .todayTodo("todo-1")) + + await adapter.didLogined(true) + #expect(adapter.snapshot.selectedMainTab == .home) + #expect(adapter.widgetRoute == .todayTodo("todo-1")) + #expect(adapter.sheetTodoId == "todo-1") + } + + @Test("로그아웃하면 적용된 위젯 경로를 지운다") + func 로그아웃하면_적용된_위젯_경로를_지운다() async { + let adapter = RootStoreTestAdapter() + + await adapter.didLogined(true) + await adapter.openWidgetRoute(.tab(.today)) + await adapter.didLogined(false) + + #expect(adapter.widgetRoute == nil) + } + + @Test("위젯 Todo 시트는 현재 선택된 탭을 유지한다") + func 위젯_Todo_시트는_현재_선택된_탭을_유지한다() async { + let adapter = RootStoreTestAdapter() + + await adapter.didLogined(true) + await adapter.selectMainTab(.profile) + await adapter.openWidgetRoute(.todayTodo("todo-2")) + + #expect(adapter.snapshot.selectedMainTab == .profile) + #expect(adapter.sheetTodoId == "todo-2") + } + + @Test("위젯 상단 경로는 Today 탭만 선택한다") + func 위젯_상단_경로는_Today_탭만_선택한다() async { + let adapter = RootStoreTestAdapter() + + await adapter.didLogined(true) + await adapter.selectMainTab(.profile) + await adapter.openWidgetRoute(.tab(.today)) + + #expect(adapter.snapshot.selectedMainTab == .today) + #expect(adapter.sheetTodoId == nil) + + await adapter.openWidgetRoute(.todayTodo("todo-1")) + #expect(adapter.snapshot.selectedMainTab == .today) + #expect(adapter.sheetTodoId == "todo-1") + } } private enum CheckAppUpdateUseCaseTestError: Error { diff --git a/Application/Presentation/PresentationShared/Sources/Structure/Todo/SystemTodoCategoryItem.swift b/Application/Presentation/PresentationShared/Sources/Structure/Todo/SystemTodoCategoryItem.swift index fad908d5..6e099571 100644 --- a/Application/Presentation/PresentationShared/Sources/Structure/Todo/SystemTodoCategoryItem.swift +++ b/Application/Presentation/PresentationShared/Sources/Structure/Todo/SystemTodoCategoryItem.swift @@ -43,16 +43,16 @@ public struct SystemTodoCategoryItem: Identifiable, Hashable { } } - public var color: UIColor { + public var color: Color { switch systemTodoCategory { - case .issue: return .systemRed - case .feature: return .systemGreen - case .improvement: return .systemCyan - case .review: return .systemOrange - case .test: return .systemPurple - case .doc: return .systemYellow - case .research: return .systemTeal - case .etc: return .systemGray + case .issue: return .red + case .feature: return .green + case .improvement: return .cyan + case .review: return .orange + case .test: return .purple + case .doc: return .yellow + case .research: return .teal + case .etc: return .gray } } } diff --git a/Application/Presentation/PresentationShared/Sources/Structure/Todo/TodoCategoryItem.swift b/Application/Presentation/PresentationShared/Sources/Structure/Todo/TodoCategoryItem.swift index 8aa790ff..280983c5 100644 --- a/Application/Presentation/PresentationShared/Sources/Structure/Todo/TodoCategoryItem.swift +++ b/Application/Presentation/PresentationShared/Sources/Structure/Todo/TodoCategoryItem.swift @@ -65,7 +65,7 @@ public struct TodoCategoryItem: Identifiable, Hashable { public var color: Color { switch category { case .system(let systemTodoCategory): - return Color(SystemTodoCategoryItem(from: systemTodoCategory).color) + return SystemTodoCategoryItem(from: systemTodoCategory).color case .user(let userTodoCategory): return UserTodoCategoryItem(from: userTodoCategory).color } diff --git a/Application/Widget/Sources/Widget/WidgetSnapshotUpdaterImpl.swift b/Application/Widget/Sources/Widget/WidgetSnapshotUpdaterImpl.swift index fabdf94b..37ead41b 100644 --- a/Application/Widget/Sources/Widget/WidgetSnapshotUpdaterImpl.swift +++ b/Application/Widget/Sources/Widget/WidgetSnapshotUpdaterImpl.swift @@ -264,6 +264,8 @@ private extension WidgetTodoSnapshot { id: id, number: number, title: title, + categoryID: categoryID, + categoryColorHex: categoryColorHex, isPinned: isPinned, createdAt: createdAt, completedAt: completedAt, diff --git a/Application/Widget/Tests/Widget/WidgetSnapshotUpdaterTests.swift b/Application/Widget/Tests/Widget/WidgetSnapshotUpdaterTests.swift index 7c3dc467..3400fba6 100644 --- a/Application/Widget/Tests/Widget/WidgetSnapshotUpdaterTests.swift +++ b/Application/Widget/Tests/Widget/WidgetSnapshotUpdaterTests.swift @@ -126,6 +126,8 @@ struct WidgetSnapshotUpdaterTests { id: "existing", number: 1, title: "existing", + categoryID: "feature", + categoryColorHex: nil, isPinned: false, dueDate: now ) @@ -211,6 +213,8 @@ struct WidgetSnapshotUpdaterTests { id: id, number: 1, title: id, + categoryID: "feature", + categoryColorHex: nil, isPinned: isPinned, createdAt: createdAt, completedAt: completedAt, diff --git a/Application/Widget/Tests/Widget/WidgetSyncEventHandlerTests.swift b/Application/Widget/Tests/Widget/WidgetSyncEventHandlerTests.swift index cef9f05a..4da84a17 100644 --- a/Application/Widget/Tests/Widget/WidgetSyncEventHandlerTests.swift +++ b/Application/Widget/Tests/Widget/WidgetSyncEventHandlerTests.swift @@ -156,6 +156,8 @@ struct WidgetSyncEventHandlerTests { id: id, number: 1, title: id, + categoryID: "feature", + categoryColorHex: nil, isPinned: false, createdAt: createdAt, completedAt: completedAt, diff --git a/README.md b/README.md index 6fa54d28..1865854c 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,22 @@ - - - - - - @@ -32,16 +28,12 @@
+ + - - + +
홈 마크다운 작성오늘 기준 Todo 확인 푸시 알림 히트맵
- - - - +
+ iPad 로그인 화면 - iPad 홈 화면 -
로그인홈iPad 로그인
@@ -83,7 +75,7 @@ - Tuist 모듈 의존성 그래프 + Tuist 모듈 의존성 그래프 (테스트 대상과 외부 패키지 대상 제외) @@ -100,7 +92,8 @@ - 작업 성격별 Todo 유형 진입점 제공 - Home에서 Todo 유형 노출 여부 및 순서 편집 -- 최근 수정 Todo 별도 섹션 제공 +- 진행 중인 개발 목표의 Todo 진행률과 최근 개발 기록 요약 +- 진행 중인 개발 목표 목록에서 목표 생성 및 상세 화면 진입 ### Todo 관리 @@ -118,10 +111,10 @@ ### Today -- 남은 일, 집중 Todo, 지연 Todo, 7일 내 마감 Todo 요약 카드 제공 -- 집중할 일, 지난 마감, 나중 일정, 일정 미정 등 기한 기준 섹션 분류 -- 보기 범위와 중요 표시 조건 기반 빠른 필터링 -- 항목별 스와이프 액션을 통한 중요 표시 및 완료 처리 +- 오늘 마감 Todo의 완료 개수와 전체 개수, 진행률을 보여주는 카드 제공 +- 지난 마감, 오늘, 7일 내 일정, 나중 일정, 일정 미정으로 Todo 분류 +- 남은 일과 중요 표시 Todo 전환 및 Todo 유형별 필터링 +- 완료한 오늘 마감 Todo를 오늘 섹션에 함께 표시 ### 알림 @@ -141,6 +134,7 @@ ### 프로필 및 설정 - 상태 메시지 직접 수정 +- 최근 수정 Todo 목록 제공 - 분기 이동 및 직접 선택, 생성/완료 활동 필터 기반 히트맵 제공 - 테마 변경과 푸시 알림 시간 설정 기능 제공 - 설정 화면에서 앱 버전, 개인정보 처리방침, 베타 테스트 링크 확인 @@ -156,10 +150,10 @@ | Architecture | Tuist Modular based Clean Architecture | | UI | SwiftUI, WidgetKit, AppIntents | | State & Async | Observable, Combine, async/await, The Composable Architecture | -| Backend | Firebase Authentication, Firestore, Cloud Functions, Cloud Messaging | +| Backend | Firebase Authentication, Firestore, Cloud Messaging, Cloud Functions, Firebase Hosting, Cloud Run (NestJS API, Staging) | | Monitoring | Firebase Analytics, Crashlytics | | Apple Frameworks | AuthenticationServices, UserNotifications, Network, CryptoKit, os.log | -| External Packages | ComposableArchitecture, OrderedCollections, GoogleSignIn, Nexa | +| External Packages | Firebase iOS SDK, GoogleSignIn, ComposableArchitecture, xctest-dynamic-overlay, Swift Collections, Nexa, Cradle, UIComposable | | Testing | swift-testing, TCA TestStore | | Tooling | Xcode, Tuist, mise, Swift Package Manager, SwiftLint, Fastlane | @@ -208,10 +202,10 @@ Debug, Staging -> staging Firebase project / Firestore (default) Release -> prod Firebase project / Firestore (default) ``` -- TestFlight archive는 `Staging`, App Store 실제 서비스 archive는 `Release` configuration을 사용함 -- GitHub Actions 배포 workflow는 PR label 기반 자동 실행 없이 수동 실행함 -- TestFlight build는 App Store 심사 제출 대상으로 승격하지 않고, 실제 배포는 같은 `MARKETING_VERSION`의 별도 `Release` configuration build로 생성함 -- build number는 TestFlight와 App Store upload가 공유하는 App Store Connect build number 공간에서 자동 증가함 +- TestFlight archive는 `Staging`, App Store 실제 서비스 archive는 `Release` configuration을 사용 +- GitHub Actions 배포 workflow는 PR label 기반 자동 실행 없이 수동 실행 +- TestFlight build는 App Store 심사 제출 대상으로 승격하지 않고, 실제 배포는 같은 `MARKETING_VERSION`의 별도 `Release` configuration build로 생성 +- build number는 TestFlight와 App Store upload가 공유하는 App Store Connect build number 공간에서 자동 증가 - TestFlight build: `bundle exec fastlane testflight_build_only` - TestFlight upload: `bundle exec fastlane deploy_testflight` diff --git a/Widget/WidgetCore/Sources/Common/WidgetDeepLink.swift b/Widget/WidgetCore/Sources/Common/WidgetDeepLink.swift index 142dc093..0fc9ed67 100644 --- a/Widget/WidgetCore/Sources/Common/WidgetDeepLink.swift +++ b/Widget/WidgetCore/Sources/Common/WidgetDeepLink.swift @@ -8,18 +8,58 @@ import Foundation public enum WidgetDeepLink { + public enum Destination: Equatable { + case today + case profile + case todo(String) + } + public static let scheme = "DevLog" public static let todayTodoHost = "today" public static let heatmapHost = "profile" + public static let todoIDQueryName = "todoId" public static var todayTodoURL: URL? { url(host: todayTodoHost) } + public static func todoURL(id: String) -> URL? { + guard !id.isEmpty else { return nil } + + var urlComponents = URLComponents() + urlComponents.scheme = scheme + urlComponents.host = todayTodoHost + urlComponents.queryItems = [URLQueryItem(name: todoIDQueryName, value: id)] + return urlComponents.url + } + public static var heatmapURL: URL? { url(host: heatmapHost) } + public static func destination(for url: URL) -> Destination? { + guard url.scheme?.lowercased() == scheme.lowercased(), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.path.isEmpty, + let host = components.host?.lowercased() else { return nil } + + switch host { + case todayTodoHost: + let queryItems = components.queryItems ?? [] + if queryItems.isEmpty { return .today } + guard queryItems.count == 1, + queryItems[0].name == todoIDQueryName, + let id = queryItems[0].value, + !id.isEmpty else { return nil } + return .todo(id) + case heatmapHost: + guard components.queryItems == nil else { return nil } + return .profile + default: + return nil + } + } + private static func url(host: String) -> URL? { var urlComponents = URLComponents() urlComponents.scheme = scheme diff --git a/Widget/WidgetCore/Sources/Today/TodayWidgetSnapshot.swift b/Widget/WidgetCore/Sources/Today/TodayWidgetSnapshot.swift index db638d8c..c2ac82ab 100644 --- a/Widget/WidgetCore/Sources/Today/TodayWidgetSnapshot.swift +++ b/Widget/WidgetCore/Sources/Today/TodayWidgetSnapshot.swift @@ -20,6 +20,8 @@ public struct WidgetTodayTodoSnapshot: Codable, Equatable { let id: String let number: Int let title: String + let categoryID: String + let categoryColorHex: String? let isPinned: Bool let dueDate: Date? } diff --git a/Widget/WidgetCore/Sources/Today/TodayWidgetSnapshotFactory.swift b/Widget/WidgetCore/Sources/Today/TodayWidgetSnapshotFactory.swift index 86d06a94..a8ebb6d5 100644 --- a/Widget/WidgetCore/Sources/Today/TodayWidgetSnapshotFactory.swift +++ b/Widget/WidgetCore/Sources/Today/TodayWidgetSnapshotFactory.swift @@ -44,6 +44,8 @@ public struct TodayWidgetSnapshotFactory { let id: String let number: Int let title: String + let categoryID: String + let categoryColorHex: String? let isPinned: Bool let dueDate: Date? @@ -52,6 +54,8 @@ public struct TodayWidgetSnapshotFactory { self.id = todo.id self.number = number self.title = todo.title + self.categoryID = todo.categoryID + self.categoryColorHex = todo.categoryColorHex self.isPinned = todo.isPinned self.dueDate = todo.dueDate } @@ -166,6 +170,8 @@ public struct TodayWidgetSnapshotFactory { id: $0.id, number: $0.number, title: $0.title, + categoryID: $0.categoryID, + categoryColorHex: $0.categoryColorHex, isPinned: $0.isPinned, dueDate: $0.dueDate ) diff --git a/Widget/WidgetCore/Tests/Common/WidgetDeepLinkTests.swift b/Widget/WidgetCore/Tests/Common/WidgetDeepLinkTests.swift new file mode 100644 index 00000000..c7d9f8eb --- /dev/null +++ b/Widget/WidgetCore/Tests/Common/WidgetDeepLinkTests.swift @@ -0,0 +1,39 @@ +// +// WidgetDeepLinkTests.swift +// WidgetCoreTests +// +// Created by opfic on 9/24/26. +// + +import Foundation +import Testing +@testable import WidgetCore + +struct WidgetDeepLinkTests { + @Test("위젯 Todo URL은 ID를 보존해 상세 목적지로 해석한다") + func 위젯_Todo_URL은_ID를_보존해_상세_목적지로_해석한다() throws { + let id = "todo/1?한글" + let url = try #require(WidgetDeepLink.todoURL(id: id)) + + #expect(WidgetDeepLink.destination(for: url) == .todo(id)) + } + + @Test("기존 위젯 URL은 탭 목적지로 해석한다") + func 기존_위젯_URL은_탭_목적지로_해석한다() throws { + let todayURL = try #require(WidgetDeepLink.todayTodoURL) + let heatmapURL = try #require(WidgetDeepLink.heatmapURL) + + #expect(WidgetDeepLink.destination(for: todayURL) == .today) + #expect(WidgetDeepLink.destination(for: heatmapURL) == .profile) + } + + @Test("Todo ID가 비어 있거나 URL 형식이 다르면 목적지를 만들지 않는다") + func Todo_ID가_비어_있거나_URL_형식이_다르면_목적지를_만들지_않는다() throws { + #expect(WidgetDeepLink.todoURL(id: "") == nil) + let url = try #require(URL(string: "DevLog://today?todoId=")) + let wrongSchemeURL = try #require(URL(string: "https://today?todoId=todo-1")) + + #expect(WidgetDeepLink.destination(for: url) == nil) + #expect(WidgetDeepLink.destination(for: wrongSchemeURL) == nil) + } +} diff --git a/Widget/WidgetCore/Tests/Heatmap/HeatmapWidgetSnapshotFactoryTests.swift b/Widget/WidgetCore/Tests/Heatmap/HeatmapWidgetSnapshotFactoryTests.swift index d5ce90f4..9522f22f 100644 --- a/Widget/WidgetCore/Tests/Heatmap/HeatmapWidgetSnapshotFactoryTests.swift +++ b/Widget/WidgetCore/Tests/Heatmap/HeatmapWidgetSnapshotFactoryTests.swift @@ -220,6 +220,8 @@ struct HeatmapWidgetSnapshotFactoryTests { id: id, number: 1, title: id, + categoryID: "feature", + categoryColorHex: nil, isPinned: false, createdAt: createdAt, completedAt: completedAt, diff --git a/Widget/WidgetCore/Tests/Today/TodayWidgetSnapshotFactoryTests.swift b/Widget/WidgetCore/Tests/Today/TodayWidgetSnapshotFactoryTests.swift index 5d2331b2..c008b243 100644 --- a/Widget/WidgetCore/Tests/Today/TodayWidgetSnapshotFactoryTests.swift +++ b/Widget/WidgetCore/Tests/Today/TodayWidgetSnapshotFactoryTests.swift @@ -29,6 +29,8 @@ struct TodayWidgetSnapshotFactoryTests { #expect(snapshot.dueSoonCount == 2) #expect(snapshot.items.count == 3) #expect(snapshot.items.map(\.title) == ["고정된 할 일", "지난 일정", "임박 일정"]) + #expect(snapshot.items.first?.categoryID == "custom") + #expect(snapshot.items.first?.categoryColorHex == "#AABBCC") } @Test("Today 위젯 스냅샷은 화면과 같은 display option 필터를 적용한다") @@ -124,6 +126,8 @@ struct TodayWidgetSnapshotFactoryTests { id: "todo-1", number: 1, title: "고정된 할 일", + categoryID: "custom", + categoryColorHex: "#AABBCC", isPinned: true, dueDate: dueSoonDate ), @@ -162,6 +166,8 @@ struct TodayWidgetSnapshotFactoryTests { id: String, number: Int, title: String, + categoryID: String = "feature", + categoryColorHex: String? = nil, isPinned: Bool, dueDate: Date? ) -> WidgetTodoSnapshot { @@ -169,6 +175,8 @@ struct TodayWidgetSnapshotFactoryTests { id: id, number: number, title: title, + categoryID: categoryID, + categoryColorHex: categoryColorHex, isPinned: isPinned, createdAt: .now, completedAt: nil, diff --git a/Widget/WidgetExtension/Resource/Localizable.xcstrings b/Widget/WidgetExtension/Resource/Localizable.xcstrings index fe6cdb37..ef3a26c3 100644 --- a/Widget/WidgetExtension/Resource/Localizable.xcstrings +++ b/Widget/WidgetExtension/Resource/Localizable.xcstrings @@ -77,36 +77,70 @@ } } }, + "widget_today_count_unit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "to do" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개" + } + } + } + }, "widget_today_description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Shows today's Todo list." + "value" : "Shows today's tasks." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘 할 일을 표시합니다." + } + } + } + }, + "widget_today_empty_small" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing to do" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "오늘 기준 Todo 목록을 표시합니다." + "value" : "할 일이 없어요" } } } }, - "widget_today_empty_message" : { + "widget_today_reinstall_message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "No tasks for today.\nTake a short break!" + "value" : "Please reinstall the app." } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "오늘은 할 일이 없어요.\n잠시 휴식을 취해보세요!" + "value" : "앱을 재설치해 주세요." } } } @@ -117,17 +151,17 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Today" + "value" : "Today's Tasks" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Today" + "value" : "오늘 할 일" } } } } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/Widget/WidgetExtension/Today/TodayTodoWidget.swift b/Widget/WidgetExtension/Today/TodayTodoWidget.swift index cc8b98a7..3f2e08d3 100644 --- a/Widget/WidgetExtension/Today/TodayTodoWidget.swift +++ b/Widget/WidgetExtension/Today/TodayTodoWidget.swift @@ -20,11 +20,12 @@ struct TodayTodoWidget: Widget { provider: TodayTodoWidgetProvider() ) { entry in TodayTodoWidgetEntryView(entry: entry) - .containerBackground(.fill.tertiary, for: .widget) + .containerBackground(Color.surface, for: .widget) .widgetURL(WidgetDeepLink.todayTodoURL) } .description("widget_today_description") .configurationDisplayName(LocalizedStringResource("widget_today_title")) - .supportedFamilies([.systemSmall, .systemMedium]) + .supportedFamilies([.systemSmall]) + .contentMarginsDisabled() } } diff --git a/Widget/WidgetExtension/Today/TodayTodoWidgetEntry.swift b/Widget/WidgetExtension/Today/TodayTodoWidgetEntry.swift index b13e0823..0d96c54a 100644 --- a/Widget/WidgetExtension/Today/TodayTodoWidgetEntry.swift +++ b/Widget/WidgetExtension/Today/TodayTodoWidgetEntry.swift @@ -10,4 +10,5 @@ import WidgetKit struct TodayTodoWidgetEntry: TimelineEntry { let date: Date let snapshot: TodayWidgetSnapshot? + let requiresReinstallation: Bool } diff --git a/Widget/WidgetExtension/Today/TodayTodoWidgetEntryView.swift b/Widget/WidgetExtension/Today/TodayTodoWidgetEntryView.swift index e6437f11..7f5efdf2 100644 --- a/Widget/WidgetExtension/Today/TodayTodoWidgetEntryView.swift +++ b/Widget/WidgetExtension/Today/TodayTodoWidgetEntryView.swift @@ -6,144 +6,153 @@ // import SwiftUI -import WidgetKit +import WidgetCore struct TodayTodoWidgetEntryView: View { let entry: TodayTodoWidgetEntry - @Environment(\.widgetFamily) private var widgetFamily + @Environment(\.colorScheme) private var colorScheme + @Environment(\.widgetContentMargins) private var widgetContentMargins var body: some View { - VStack(alignment: .leading) { - Text("widget_today_title") - .font(.headline) - - Spacer() - - if let snapshot = entry.snapshot { - content(snapshot) + Group { + if entry.requiresReinstallation { + reinstallContent } else { - emptyState + smallContent } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var header: some View { + Text("widget_today_title") + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + private var reinstallContent: some View { + VStack(alignment: .leading) { + header + Spacer() + Text("widget_today_reinstall_message") + .font(.caption) + .foregroundStyle(Color.textSecondary) + .lineLimit(3) Spacer() } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(widgetContentMargins) } - @ViewBuilder - private func content(_ snapshot: TodayWidgetSnapshot) -> some View { - switch widgetFamily { - case .systemSmall: - VStack(alignment: .leading, spacing: 4) { - Text("\(snapshot.totalCount)") - .font(.system(size: 28, weight: .bold)) - - if let item = displayedItems(from: snapshot).first { - todoRow(item) + private var smallContent: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + header + + Spacer(minLength: 8) + + if let snapshot = entry.snapshot { + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text("\(snapshot.totalCount)") + .font(.largeTitle.bold()) + Text("widget_today_count_unit") + .font(.callout.weight(.semibold)) + } + .foregroundStyle(Color.accent) } else { - Text("widget_today_empty_message") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) + placeholder(width: 42, height: 30) } + + Spacer(minLength: 8) } - case .systemMedium: - let items = displayedItems(from: snapshot) - VStack(alignment: .leading, spacing: 6) { - if items.isEmpty { - Text("widget_today_empty_message") - .multilineTextAlignment(.center) - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(items, id: \.id) { item in - todoRow(item, lineLimit: 1) - } + .padding(.top, widgetContentMargins.top) + .padding(.leading, widgetContentMargins.leading) + .padding(.trailing, widgetContentMargins.trailing) + + Divider() + + if let footerURL { + Link(destination: footerURL) { + footerContent + .padding(.top, 8) + .padding(.leading, widgetContentMargins.leading) + .padding(.trailing, widgetContentMargins.trailing) + .padding(.bottom, widgetContentMargins.bottom) + .contentShape(.rect) } + .buttonStyle(.plain) + } else { + footerContent + .padding(.top, 8) + .padding(.leading, widgetContentMargins.leading) + .padding(.trailing, widgetContentMargins.trailing) + .padding(.bottom, widgetContentMargins.bottom) } - .frame(maxWidth: .infinity, alignment: .leading) - default: - EmptyView() } } + private var footerURL: URL? { + guard let item = entry.snapshot?.items.first else { return WidgetDeepLink.todayTodoURL } + return WidgetDeepLink.todoURL(id: item.id) ?? WidgetDeepLink.todayTodoURL + } + @ViewBuilder - private var emptyState: some View { - switch widgetFamily { - case .systemSmall: - GeometryReader { proxy in - VStack(alignment: .leading, spacing: 4) { - placeholderTodoCount() - placeholderTodoRow(width: placeholderTodoRowWidth(in: proxy.size.width, at: 0)) - } - } - .frame(height: 56) - .frame(maxWidth: .infinity, alignment: .leading) - case .systemMedium: - GeometryReader { proxy in - VStack(alignment: .leading, spacing: 6) { - ForEach(0..<3, id: \.self) { index in - placeholderTodoRow(width: placeholderTodoRowWidth(in: proxy.size.width, at: index)) - } - } + private var footerContent: some View { + if let snapshot = entry.snapshot { + if let item = snapshot.items.first { + todoRow(item) + } else { + Text("widget_today_empty_small") + .font(.caption2) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.8) } - .frame(height: 56) - .frame(maxWidth: .infinity, alignment: .leading) - default: - EmptyView() + } else { + placeholderRow } } - private func displayedItems(from snapshot: TodayWidgetSnapshot) -> [WidgetTodayTodoSnapshot] { - Array(snapshot.items.prefix(3)) - } + private func todoRow(_ item: WidgetTodayTodoSnapshot) -> some View { + let style = WidgetTodoCategoryStyle( + categoryID: item.categoryID, + colorHex: item.categoryColorHex + ) + + return HStack(spacing: 6) { + Image(systemName: style.symbolName) + .font(.caption2.bold()) + .foregroundStyle(colorScheme == .dark ? Color.white : style.color) + .frame(width: 22, height: 22) + .background( + colorScheme == .dark ? style.color : style.color.opacity(0.12), + in: RoundedRectangle(cornerRadius: 7) + ) - private func todoRow(_ item: WidgetTodayTodoSnapshot, lineLimit: Int? = nil) -> some View { - HStack(spacing: 6) { Text("#\(item.number)") - .font(.caption2) - .foregroundStyle(.secondary) - - if item.isPinned { - Image(systemName: "star.fill") - .font(.caption2) - .foregroundStyle(.orange) - } + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.accent) Text(item.title) .font(.caption) - .lineLimit(lineLimit) + .lineLimit(1) + .minimumScaleFactor(0.8) } + .frame(maxWidth: .infinity, minHeight: 24, alignment: .leading) } - private func placeholderTodoCount() -> some View { - RoundedRectangle(cornerRadius: 4) - .fill(Color.secondary.opacity(0.18)) - .frame(width: 22, height: 28) - } - - private func placeholderTodoRow(width: CGFloat) -> some View { + private var placeholderRow: some View { HStack(spacing: 6) { - RoundedRectangle(cornerRadius: 3) - .fill(Color.secondary.opacity(0.18)) - .frame(width: 22, height: 8) - - RoundedRectangle(cornerRadius: 3) - .fill(Color.secondary.opacity(0.18)) - .frame(width: width, height: 8) + placeholder(width: 22, height: 22) + placeholder(width: 22, height: 8) + placeholder(width: 70, height: 8) } + .frame(height: 24) } - private func placeholderTodoRowWidth(in availableWidth: CGFloat, at index: Int) -> CGFloat { - let titleAreaWidth = max(availableWidth - 28, 0) - - switch index { - case 0: - return titleAreaWidth * 2 / 3 - case 1: - return titleAreaWidth / 2 - default: - return titleAreaWidth * 3 / 5 - } + private func placeholder(width: CGFloat, height: CGFloat) -> some View { + RoundedRectangle(cornerRadius: 4) + .fill(Color.textSecondary.opacity(0.18)) + .frame(width: width, height: height) } } diff --git a/Widget/WidgetExtension/Today/TodayTodoWidgetProvider.swift b/Widget/WidgetExtension/Today/TodayTodoWidgetProvider.swift index 0f8bf60d..0195860d 100644 --- a/Widget/WidgetExtension/Today/TodayTodoWidgetProvider.swift +++ b/Widget/WidgetExtension/Today/TodayTodoWidgetProvider.swift @@ -15,7 +15,7 @@ struct TodayTodoWidgetProvider: AppIntentTimelineProvider { // 위젯 갤러리나 로딩 전 상태에서 즉시 표시할 기본 엔트리. func placeholder(in context: Context) -> TodayTodoWidgetEntry { - .init(date: .now, snapshot: nil) + .init(date: .now, snapshot: nil, requiresReinstallation: false) } // 현재 시점의 단일 스냅샷을 만들어 미리보기와 일시적인 렌더링에 사용한다. @@ -23,11 +23,7 @@ struct TodayTodoWidgetProvider: AppIntentTimelineProvider { for configuration: TodayTodoWidgetConfigurationIntent, in context: Context ) async -> TodayTodoWidgetEntry { - let snapshot = try? store.loadTodaySnapshot() - return .init( - date: .now, - snapshot: snapshot - ) + makeEntry() } // 실제 위젯이 사용할 타임라인 엔트리를 구성한다. @@ -36,12 +32,8 @@ struct TodayTodoWidgetProvider: AppIntentTimelineProvider { for configuration: TodayTodoWidgetConfigurationIntent, in context: Context ) async -> Timeline { - let snapshot = try? store.loadTodaySnapshot() let entries: [TodayTodoWidgetEntry] = [ - .init( - date: .now, - snapshot: snapshot - ) + makeEntry() ] return Timeline( @@ -49,4 +41,16 @@ struct TodayTodoWidgetProvider: AppIntentTimelineProvider { policy: .never ) } + + private func makeEntry() -> TodayTodoWidgetEntry { + do { + return .init( + date: .now, + snapshot: try store.loadTodaySnapshot(), + requiresReinstallation: false + ) + } catch { + return .init(date: .now, snapshot: nil, requiresReinstallation: true) + } + } } diff --git a/Widget/WidgetExtension/Today/TodayWidgetSnapshot.swift b/Widget/WidgetExtension/Today/TodayWidgetSnapshot.swift index 60f1dc83..58fb7852 100644 --- a/Widget/WidgetExtension/Today/TodayWidgetSnapshot.swift +++ b/Widget/WidgetExtension/Today/TodayWidgetSnapshot.swift @@ -20,6 +20,8 @@ struct WidgetTodayTodoSnapshot: Decodable, Equatable { let id: String let number: Int let title: String + let categoryID: String + let categoryColorHex: String? let isPinned: Bool let dueDate: Date? } diff --git a/Widget/WidgetExtension/Today/WidgetTodoCategoryStyle.swift b/Widget/WidgetExtension/Today/WidgetTodoCategoryStyle.swift new file mode 100644 index 00000000..97e34d46 --- /dev/null +++ b/Widget/WidgetExtension/Today/WidgetTodoCategoryStyle.swift @@ -0,0 +1,64 @@ +// +// WidgetTodoCategoryStyle.swift +// WidgetExtension +// +// Created by opfic on 9/24/26. +// + +import SwiftUI + +struct WidgetTodoCategoryStyle { + let symbolName: String + let color: Color + + init(categoryID: String, colorHex: String?) { + switch categoryID { + case "issue": + symbolName = "exclamationmark.triangle" + color = .red + case "feature": + symbolName = "sparkles" + color = .green + case "improvement": + symbolName = "arrow.triangle.2.circlepath" + color = .cyan + case "review": + symbolName = "eye" + color = .orange + case "test": + symbolName = "checkmark.shield" + color = .purple + case "doc": + symbolName = "doc.text" + color = .yellow + case "research": + symbolName = "magnifyingglass" + color = .teal + case "etc": + symbolName = "ellipsis" + color = .gray + default: + if let colorHex { + symbolName = "tray.fill" + color = Self.color(from: colorHex) ?? .gray + } else { + symbolName = "questionmark" + color = .gray + } + } + } + + private static func color(from hexString: String?) -> Color? { + guard let hexString else { return nil } + let trimmedHex = hexString.trimmingCharacters(in: .whitespacesAndNewlines) + let sanitizedHex = trimmedHex.hasPrefix("#") ? String(trimmedHex.dropFirst()) : trimmedHex + guard sanitizedHex.count == 6, + let hexValue = Int(sanitizedHex, radix: 16) else { return nil } + + return Color( + red: Double((hexValue >> 16) & 0xFF) / 255, + green: Double((hexValue >> 8) & 0xFF) / 255, + blue: Double(hexValue & 0xFF) / 255 + ) + } +} diff --git a/docs/graph.png b/docs/graph.png index ef3c6a05..bfee0c3f 100644 Binary files a/docs/graph.png and b/docs/graph.png differ diff --git a/docs/notification.png b/docs/notification.png index 2c6f399f..80d5aae2 100644 Binary files a/docs/notification.png and b/docs/notification.png differ