From 23ec064448b1c72186ba89dc6957f6f46fe8d843 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 11 Sep 2026 02:41:52 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20Color.x=20=ED=98=95=ED=83=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/Component/LoginButton.swift | 6 +-- .../Entry/Sources/Login/LoginView.swift | 2 +- .../Entry/Sources/Root/RootView.swift | 2 +- .../Sources/Common/ColorAsset.swift | 39 ------------------- .../Sources/Extension/Color+Assets.swift | 28 +++++++++++++ .../Sources/Profile/ProfileView.swift | 14 +++---- Application/Presentation/Project.swift | 8 +++- Widget/WidgetExtension/Project.swift | 2 + .../AccentColor.colorset/Contents.json | 11 ------ 9 files changed, 49 insertions(+), 63 deletions(-) delete mode 100644 Application/Presentation/PresentationShared/Sources/Common/ColorAsset.swift create mode 100644 Application/Presentation/PresentationShared/Sources/Extension/Color+Assets.swift delete mode 100644 Widget/WidgetExtension/Resource/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Application/Presentation/Entry/Sources/Common/Component/LoginButton.swift b/Application/Presentation/Entry/Sources/Common/Component/LoginButton.swift index c8ad9c5f..3c063ae9 100644 --- a/Application/Presentation/Entry/Sources/Common/Component/LoginButton.swift +++ b/Application/Presentation/Entry/Sources/Common/Component/LoginButton.swift @@ -33,7 +33,7 @@ struct LoginButton: View { Group { if showsProgressView { ProgressView() - .tint(Color(asset: .accentColor)) + .tint(Color.accent) } else { Text(text) .foregroundStyle(.primary) @@ -44,8 +44,8 @@ struct LoginButton: View { .frame(width: 300, height: height + 24) .background { RoundedRectangle(cornerRadius: 12) - .stroke(Color(asset: .border), lineWidth: 3) - .fill(Color(asset: .surface)) + .stroke(Color.border, lineWidth: 3) + .fill(Color.surface) } .overlay(alignment: .leading) { if let logo, !showsProgressView { diff --git a/Application/Presentation/Entry/Sources/Login/LoginView.swift b/Application/Presentation/Entry/Sources/Login/LoginView.swift index 40e9c03c..9a8ecf3f 100644 --- a/Application/Presentation/Entry/Sources/Login/LoginView.swift +++ b/Application/Presentation/Entry/Sources/Login/LoginView.swift @@ -40,7 +40,7 @@ struct LoginView: View { .multilineTextAlignment(.center) Text(String(localized: "login_intro_description", bundle: PresentationResources.bundle)) .padding(.horizontal) - .foregroundStyle(Color(asset: .textTertiary)) + .foregroundStyle(Color.textTertiary) .multilineTextAlignment(.center) } Spacer() diff --git a/Application/Presentation/Entry/Sources/Root/RootView.swift b/Application/Presentation/Entry/Sources/Root/RootView.swift index 16e3d3ae..73dbf43e 100644 --- a/Application/Presentation/Entry/Sources/Root/RootView.swift +++ b/Application/Presentation/Entry/Sources/Root/RootView.swift @@ -33,7 +33,7 @@ public struct RootView: View { public var body: some View { ZStack { - Color(asset: .appBackground).ignoresSafeArea() + Color.appBackground.ignoresSafeArea() if let signIn = store.signIn { if signIn { MainView( diff --git a/Application/Presentation/PresentationShared/Sources/Common/ColorAsset.swift b/Application/Presentation/PresentationShared/Sources/Common/ColorAsset.swift deleted file mode 100644 index b0b2be49..00000000 --- a/Application/Presentation/PresentationShared/Sources/Common/ColorAsset.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// ColorAsset.swift -// PresentationShared -// -// Created by opfic on 9/9/26. -// - -import SwiftUI - -public enum ColorAsset { - case accentColor - case appBackground - case border - case danger - case dangerContainer - case info - case infoContainer - case onPrimaryContainer - case primaryContainer - case success - case successContainer - case surface - case surfaceSecondary - case textSecondary - case textTertiary - case warning - case warningContainer - - fileprivate var name: String { - let name = String(describing: self) - return name.prefix(1).uppercased() + name.dropFirst() - } -} - -public extension Color { - init(asset: ColorAsset) { - self.init(asset.name, bundle: PresentationResources.bundle) - } -} diff --git a/Application/Presentation/PresentationShared/Sources/Extension/Color+Assets.swift b/Application/Presentation/PresentationShared/Sources/Extension/Color+Assets.swift new file mode 100644 index 00000000..4da32ecc --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Extension/Color+Assets.swift @@ -0,0 +1,28 @@ +// +// Color+Assets.swift +// PresentationShared +// +// Created by opfic on 9/11/26. +// + +import SwiftUI + +public extension Color { + static let accent = Color("AccentColor", bundle: PresentationResources.bundle) + static let appBackground = Color("AppBackground", bundle: PresentationResources.bundle) + static let border = Color("Border", bundle: PresentationResources.bundle) + static let danger = Color("Danger", bundle: PresentationResources.bundle) + static let dangerContainer = Color("DangerContainer", bundle: PresentationResources.bundle) + static let info = Color("Info", bundle: PresentationResources.bundle) + static let infoContainer = Color("InfoContainer", bundle: PresentationResources.bundle) + static let onPrimaryContainer = Color("OnPrimaryContainer", bundle: PresentationResources.bundle) + static let primaryContainer = Color("PrimaryContainer", bundle: PresentationResources.bundle) + static let success = Color("Success", bundle: PresentationResources.bundle) + static let successContainer = Color("SuccessContainer", bundle: PresentationResources.bundle) + static let surface = Color("Surface", bundle: PresentationResources.bundle) + static let surfaceSecondary = Color("SurfaceSecondary", bundle: PresentationResources.bundle) + static let textSecondary = Color("TextSecondary", bundle: PresentationResources.bundle) + static let textTertiary = Color("TextTertiary", bundle: PresentationResources.bundle) + static let warning = Color("Warning", bundle: PresentationResources.bundle) + static let warningContainer = Color("WarningContainer", bundle: PresentationResources.bundle) +} diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift index 95996a27..e1c45f73 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -50,7 +50,7 @@ public struct ProfileView: View { .refreshable { await store.send(.refresh).finish() } .toolbarVisibility(.hidden, for: .navigationBar) .frame(maxWidth: .infinity) - .background(Color(asset: .appBackground)) + .background(Color.appBackground) .navigationDestination(for: ProfileRoute.self, destination: destinationView) } .onChange(of: isSelected, initial: true) { _, isSelected in @@ -83,12 +83,12 @@ public struct ProfileView: View { path.append(.settings) } label: { Image(systemName: "gearshape") - .foregroundStyle(Color(asset: .textTertiary)) + .foregroundStyle(Color.textTertiary) } .adaptiveButtonStyle() } Text("꾸준히 쌓아온 개발 기록을 확인하세요") - .foregroundStyle(Color(asset: .textSecondary)) + .foregroundStyle(Color.textSecondary) .font(.caption) } } @@ -370,7 +370,7 @@ private struct ProfileCard: View { .resizable() .scaledToFill() .symbolRenderingMode(.palette) - .foregroundStyle(Color(asset: .onPrimaryContainer), Color(asset: .primaryContainer)) + .foregroundStyle(Color.onPrimaryContainer, Color.primaryContainer) } } .frame(width: 60, height: 60) @@ -413,7 +413,7 @@ private struct ProfileCard: View { .padding(8) .background( RoundedRectangle(cornerRadius: 10) - .fill(Color(asset: .primaryContainer)) + .fill(Color.primaryContainer) ) if store.showDoneButton { Button { @@ -474,7 +474,7 @@ private struct RecentActivityCard: View { } else if store.recentTodos.isEmpty { Text(String(localized: "profile_recent_empty", bundle: PresentationResources.bundle)) .font(.callout) - .foregroundStyle(Color(asset: .textSecondary)) + .foregroundStyle(Color.textSecondary) .frame(maxWidth: .infinity, minHeight: 80) } else { VStack(spacing: 0) { @@ -487,7 +487,7 @@ private struct RecentActivityCard: View { Spacer(minLength: 0) Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) - .foregroundStyle(Color(asset: .textTertiary)) + .foregroundStyle(Color.textTertiary) } .contentShape(.rect) } diff --git a/Application/Presentation/Project.swift b/Application/Presentation/Project.swift index 068feeda..354ab209 100644 --- a/Application/Presentation/Project.swift +++ b/Application/Presentation/Project.swift @@ -13,6 +13,12 @@ let frameworkBuildSettings = Settings.devlog( ] ) +let presentationSharedBuildSettings = { + var settings = frameworkBuildSettings + settings.base["ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS"] = "NO" + return settings +}() + let thirdPartyDependency: TargetDependency = .project( target: "ThirdParty", path: "../../Libraries/ThirdParty" @@ -53,7 +59,7 @@ let project = Project( ), thirdPartyDependency, ], - settings: frameworkBuildSettings + settings: presentationSharedBuildSettings ), .target( name: "PresentationSharedTests", diff --git a/Widget/WidgetExtension/Project.swift b/Widget/WidgetExtension/Project.swift index 47c3bdfb..55f45920 100644 --- a/Widget/WidgetExtension/Project.swift +++ b/Widget/WidgetExtension/Project.swift @@ -26,6 +26,7 @@ let project = Project( ], resources: [ "Resource/Assets.xcassets", + "../../Application/Presentation/PresentationShared/Resources/Assets.xcassets", "Resource/Localizable.xcstrings" ], entitlements: .file(path: "Resource/DevLogWidget.entitlements"), @@ -38,6 +39,7 @@ let project = Project( settings: .devlog( versionXcconfigPath: "../../Application/Shared/Version.xcconfig", base: [ + "ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS": "YES", "CODE_SIGN_STYLE": "Automatic", "ENABLE_USER_SCRIPT_SANDBOXING": "NO" ] diff --git a/Widget/WidgetExtension/Resource/Assets.xcassets/AccentColor.colorset/Contents.json b/Widget/WidgetExtension/Resource/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb878970..00000000 --- a/Widget/WidgetExtension/Resource/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} From 4eb1bb727a3ffef7f8fadd8149ed79b777ee5366 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 11 Sep 2026 09:37:12 +0900 Subject: [PATCH 2/7] =?UTF-8?q?ui:=20=ED=83=9C=EA=B7=B8=20=EC=83=89=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PresentationShared/Sources/Common/Component/Tags.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/Tags.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/Tags.swift index f2a94e0a..55459c84 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/Tags.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/Tags.swift @@ -43,7 +43,7 @@ public struct Tag: View { public var body: some View { HStack(spacing: 4) { Text(name) - .foregroundStyle(.blue) + .foregroundStyle(Color.accent) .bold() .lineLimit(1) .fixedSize() @@ -74,7 +74,7 @@ public struct Tag: View { .frame(width: height, height: height) .symbolRenderingMode(.palette) .foregroundStyle( - .blue, + Color.accent, .black.opacity(colorScheme == .light ? 0 : 0.4) ) @@ -84,7 +84,7 @@ public struct Tag: View { } .background { Capsule() - .fill(.blue.opacity(0.2)) + .fill(Color.accent.opacity(0.2)) } } } From 616fe8da970425c4c0e954df4a444d6ebf8e590d Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 11 Sep 2026 15:20:33 +0900 Subject: [PATCH 3/7] =?UTF-8?q?ui:=20=ED=94=BC=EA=B7=B8=EB=A7=88=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Todo/Editor/TodoEditorFeature.swift | 44 +- .../Sources/Todo/Editor/TodoEditorView.swift | 679 +++++++++++------- .../Todo/TodoEditorFeatureTestDoubles.swift | 22 +- .../Tests/Todo/TodoEditorFeatureTests.swift | 27 +- 4 files changed, 475 insertions(+), 297 deletions(-) diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift index 2790085f..6da6cf62 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift @@ -15,7 +15,8 @@ public struct TodoEditorFeature { @ObservableState public struct State: Equatable { @Presents public var alert: AlertState? - @Presents public var sheet: SheetState? + public var isInspectorPresented = false + public var inspectorContent = InspectorContent.options public var isCompleted: Bool = false public var completedAt: Date? public var isPinned: Bool = false @@ -27,6 +28,7 @@ public struct TodoEditorFeature { public var tags: OrderedSet = [] public var tagText: String = "" public var tabViewTag: EditorTab = .editor + public var editorHeaderHeight = CGFloat.zero public var categories: [TodoCategoryItem] = [] public var category = TodoCategoryItem(from: .system(.etc)) public var saveResult: SaveResult? @@ -96,10 +98,8 @@ public struct TodoEditorFeature { } } - @ObservableState - @CasePathable - public enum SheetState: Equatable { - case info + public enum InspectorContent: Equatable { + case options case todo(TodoIdItem) } @@ -115,24 +115,19 @@ public struct TodoEditorFeature { public enum Action: BindableAction, Equatable { case alert(PresentationAction) - case sheet(PresentationAction) case binding(BindingAction) case delegate(Delegate) case onAppear case addTag(String) case removeTag(String) case setCompleted(Bool) - case setSheet(SheetState?) + case showInspector(InspectorContent) case upsertTodo case createSucceeded case saveFailed case updateSucceeded(Todo) case loading(LoadingFeature.Action) - public enum Sheet: Equatable { - case tapCloseButton - } - public enum Delegate: Equatable { case created case updated(Todo) @@ -159,12 +154,6 @@ public struct TodoEditorFeature { switch action { case .alert: break - case .sheet(.dismiss): - state.sheet = nil - case .sheet(.presented(.tapCloseButton)): - state.sheet = nil - case .sheet: - break case .binding(\.content): if state.tabViewTag == .preview { return resolveMarkdownEffect(content: state.content) @@ -197,8 +186,13 @@ public struct TodoEditorFeature { state.completedAt = isCompleted ? now : nil } state.isCompleted = isCompleted - case .setSheet(let sheet): - state.sheet = sheet + case .showInspector(let content): + if content == .options && state.inspectorContent == .options { + state.isInspectorPresented.toggle() + } else { + state.isInspectorPresented = true + } + state.inspectorContent = content case .upsertTodo: state.saveResult = nil if state.originalDraft == nil { @@ -219,18 +213,6 @@ public struct TodoEditorFeature { return .none } .ifLet(\.$alert, action: \.alert) - .ifLet(\.$sheet, action: \.sheet) { - TodoEditorSheetFeature() - } - } -} - -private struct TodoEditorSheetFeature: Reducer { - typealias State = TodoEditorFeature.SheetState - typealias Action = TodoEditorFeature.Action.Sheet - - var body: some ReducerOf { - EmptyReducer() } } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index 82256726..cdd01589 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -12,8 +12,10 @@ import Domain public struct TodoEditorView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac + @Environment(\.safeAreaInsets) private var safeAreaInsets @State var store: StoreOf @FocusState private var field: Field? private let calendar = Calendar.current @@ -35,114 +37,214 @@ public struct TodoEditorView: View { public var body: some View { NavigationStack { - selectedContent - .onTapGesture { - field = .content + ZStack { + Color.appBackground.ignoresSafeArea() + VStack(spacing: 8) { + ToolBar( + store: store, + showsActions: !movesActionsToInspector, + onClose: close, + onSubmit: submit + ) + GeometryReader { geometry in + ScrollView { + VStack(spacing: 16) { + VStack(alignment: .trailing, spacing: 16) { + TitleField(store: store, field: _field) + ModePicker(store: store, field: _field) + .frame(maxWidth: horizontalSizeClass == .regular ? 280 : .infinity) + } + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + store.send(.binding(.set(\.editorHeaderHeight, height))) + } + ContentView( + store: store, + field: _field, + minimumHeight: max( + 0, + geometry.size.height - store.editorHeaderHeight - 16 - safeAreaInsets.bottom + ) + ) + .onTapGesture { + field = .content + } + } + .padding(.horizontal) + } + .contentMargins(.vertical, 16, for: .scrollContent) + } + } } .onAppear { store.send(.onAppear) } .onChange(of: store.saveResult) { _, result in handleSaveResult(result) } - .navigationTitle(store.navigationTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbarBackground(.background, for: .navigationBar) - .sheet( - item: $store.scope(state: \.sheet, action: \.sheet) - .activePresentation(when: isTabContentActive) - ) { store in - sheetContent(store) - } - .toolbar { toolbarContent } + .toolbarVisibility(.hidden, for: .navigationBar) .prominentAlert(store, state: \.alert, action: \.alert) + .inspector(isPresented: $store.isInspectorPresented) { + switch store.inspectorContent { + case .options: + InspectorView( + store: store, + showsEditorActions: movesActionsToInspector, + onSubmit: submit + ) { + store.send(.binding(.set(\.isInspectorPresented, false))) + } + case .todo(let item): + NavigationStack { + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State(todoId: item.id, showEditButton: false) + ) { + TodoDetailFeature() + }) + .id(item.id) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + store.send(.showInspector(.options)) + } label: { + Label( + String( + localized: "todo_options_section", + bundle: PresentationResources.bundle + ), + systemImage: "chevron.left" + ) + } + } + if movesActionsToInspector { + ToolbarItem(placement: .topBarTrailing) { + EditorToolbarActions(store: store, onSubmit: submit) + } + } else { + ToolbarTrailingButton { + store.send(.binding(.set(\.isInspectorPresented, false))) + } + } + } + } + } + } } } - @ViewBuilder - private var selectedContent: some View { - if store.tabViewTag == .editor { - editorContent + private var movesActionsToInspector: Bool { + store.isInspectorPresented && (isiOSAppOnMac || horizontalSizeClass == .regular) + } + + private func close() { + if let onClose { + onClose() } else { - previewContent + dismiss() } } - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - if !isiOSAppOnMac { - ToolbarLeadingButton { close() } + private func submit() { + store.send(.upsertTodo) + } + + private func handleSaveResult(_ result: TodoEditorFeature.SaveResult?) { + switch result { + case .created: + onCreateSuccess?() + case .updated(let todo): + onUpdateSuccess?(todo) + case .none: + break } - ToolbarItem(placement: .topBarTrailing) { + } +} + +private struct ToolBar: View { + let store: StoreOf + let showsActions: Bool + let onClose: () -> Void + let onSubmit: () -> Void + @ScaledMetric(relativeTo: .title) private var iconSize = UIFont.preferredFont( + forTextStyle: .title1, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) + ).lineHeight + + var body: some View { + HStack { Button { - store.send(.setSheet(.info)) + onClose() } label: { - Image(systemName: "info.circle") - } - } - if store.isLoading { - if #available(iOS 26.0, *) { - ToolbarSpacer(.fixed, placement: .topBarTrailing) + if #available(iOS 26.0, *) { + Image(systemName: "xmark") + .frame(width: iconSize, height: iconSize) + } else { + Text(String(localized: "common_close", bundle: PresentationResources.bundle)) + } } - ToolbarItem(placement: .topBarTrailing) { - ProgressView() + .font(.title) + .adaptiveButtonStyle(shape: .circle) + Spacer() + Text(store.navigationTitle) + .font(.title3.bold()) + Spacer() + if showsActions { + EditorToolbarActions(store: store, onSubmit: onSubmit) } - } else { - ToolbarTrailingButton { - submit() - } - .disabled(!store.isReadyToSubmit) } + .padding(.horizontal) + .padding(.top, 12) } +} - private var editorContent: some View { - ScrollView { - LazyVStack(spacing: 10) { - titleSection - LazyVStack( - alignment: .leading, - spacing: 0, - pinnedViews: [.sectionHeaders] - ) { - Section { - tabView - } header: { - if !isiOSAppOnMac { - tabPicker - .padding(.horizontal) - } - } - } - } - } - } +private struct EditorToolbarActions: View { + let store: StoreOf + let onSubmit: () -> Void + @ScaledMetric(relativeTo: .title) private var iconSize = UIFont.preferredFont( + forTextStyle: .title1, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) + ).lineHeight - private var previewContent: some View { - VStack(spacing: 10) { - titleSection - if !isiOSAppOnMac { - tabPicker - .padding(.horizontal) + var body: some View { + HStack { + Button { + store.send(.showInspector(.options)) + } label: { + Image(systemName: "info.circle") + .frame(width: iconSize, height: iconSize) + .foregroundStyle(Color.primary) } - tabView - } - } - - @ViewBuilder - private var titleSection: some View { - Group { - if isiOSAppOnMac { - HStack(spacing: 12) { - titleField - tabPicker - .frame(width: 180) - } + .font(.title) + .adaptiveButtonStyle(shape: .circle, color: Color.surface) + if store.isLoading { + ProgressView() + .frame(width: iconSize, height: iconSize) + .adaptiveButtonStyle(shape: .circle, color: Color.surface) } else { - titleField + Button { + onSubmit() + } label: { + if #available(iOS 26.0, *) { + Image(systemName: "checkmark") + .frame(width: iconSize, height: iconSize) + .foregroundStyle(Color.primary) + } else { + Text(String(localized: "todo_manage_save", bundle: PresentationResources.bundle)) + .foregroundStyle(Color.primary) + } + } + .font(.title) + .adaptiveButtonStyle(shape: .circle, color: Color.surface) + .disabled(!store.isReadyToSubmit) } } - .padding(.horizontal) } +} + +private struct TitleField: View { + @Bindable var store: StoreOf + @FocusState var field: Field? - private var titleField: some View { + var body: some View { TextField( "", text: $store.title, @@ -155,34 +257,75 @@ public struct TodoEditorView: View { .foregroundColor(Color.secondary), ) .font(.title2) - .frame(height: 30) .focused($field, equals: .title) + .frame(height: 30) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } + } +} + +private struct ModePicker: View { + @Bindable var store: StoreOf + @FocusState var field: Field? + + var body: some View { + HStack(spacing: 0) { + modeButton( + String(localized: "todo_write", bundle: PresentationResources.bundle), + tab: .editor + ) + modeButton( + String(localized: "todo_preview", bundle: PresentationResources.bundle), + tab: .preview + ) + } + .padding(2) + .background(Color.border, in: RoundedRectangle(cornerRadius: 16)) } - private var tabPicker: some View { - Picker( - "", - selection: Binding( - get: { store.tabViewTag }, - set: { tag in - if tag == .editor { - store.send(.binding(.set(\.tabViewTag, .editor))) - field = .content - } else { - transitionToPreview() + @ViewBuilder + private func modeButton(_ title: String, tab: TodoEditorFeature.EditorTab) -> some View { + let isSelected = store.tabViewTag == tab + + Button { + if tab == .editor { + store.send(.binding(.set(\.tabViewTag, .editor))) + field = .content + } else { + field = nil + DispatchQueue.main.async { + store.send(.binding(.set(\.tabViewTag, .preview))) + } + } + } label: { + Text(title) + .font(.body) + .foregroundStyle(isSelected ? Color.accent : Color.textSecondary) + .frame(maxWidth: .infinity, minHeight: 36) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 14) + .fill(Color.surface) + .shadow(color: Color.textSecondary.opacity(0.08), radius: 2, y: 2) } } - ) - ) { - Text(String(localized: "todo_write", bundle: PresentationResources.bundle)) - .tag(TodoEditorFeature.EditorTab.editor) - Text(String(localized: "todo_preview", bundle: PresentationResources.bundle)) - .tag(TodoEditorFeature.EditorTab.preview) + .contentShape(.rect) } - .pickerStyle(.segmented) + .buttonStyle(.plain) } +} + +private struct ContentView: View { + @Bindable var store: StoreOf + @FocusState var field: Field? + let minimumHeight: CGFloat - private var tabView: some View { + var body: some View { Group { if store.tabViewTag == .editor { VStack(alignment: .leading, spacing: 8) { @@ -205,12 +348,18 @@ public struct TodoEditorView: View { TodoMarkdownContentView( content: store.content, referenceItems: store.referenceItems, - onOpenTodoID: { store.send(.setSheet(.todo(TodoIdItem(id: $0)))) } + onOpenTodoID: { store.send(.showInspector(.todo(TodoIdItem(id: $0)))) } ) } } } - .padding(.top, 10) + .padding(.vertical, 10) + .frame(minHeight: minimumHeight, alignment: .topLeading) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } } private var markdownHint: some View { @@ -230,168 +379,202 @@ public struct TodoEditorView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.vertical, 8) } +} - private func submit() { - store.send(.upsertTodo) - } - - private func close() { - if let onClose { - onClose() - } else { - dismiss() - } - } - - private func transitionToPreview() { - field = nil - - DispatchQueue.main.async { - store.send(.binding(.set(\.tabViewTag, .preview))) - } - } +private struct InspectorView: View { + @Bindable var store: StoreOf + @FocusState private var isTagFieldFocused: Bool + @ScaledMetric(relativeTo: .title) private var iconSize = UIFont.preferredFont( + forTextStyle: .title1, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) + ).lineHeight + var showsEditorActions = false + var onSubmit: () -> Void = {} + let onClose: () -> Void + private let calendar = Calendar.current - private func handleSaveResult(_ result: TodoEditorFeature.SaveResult?) { - switch result { - case .created: - onCreateSuccess?() - case .updated(let todo): - onUpdateSuccess?(todo) - case .none: - break + var body: some View { + ZStack { + Color.appBackground.ignoresSafeArea() + VStack(spacing: 8) { + toolBar + content + } } } - @ViewBuilder - private func sheetContent( - _ sheetStore: Store - ) -> some View { - switch sheetStore.state { - case .info: - TodoEditorInfoSheetView(store: store) { - sheetStore.send(.tapCloseButton) - } - case .todo(let item): - NavigationStack { - TodoDetailView(store: Store( - initialState: TodoDetailFeature.State(todoId: item.id, showEditButton: false) - ) { - TodoDetailFeature() - }) - .toolbar { - ToolbarLeadingButton { - sheetStore.send(.tapCloseButton) - } + private var toolBar: some View { + HStack { + Spacer() + if showsEditorActions { + EditorToolbarActions(store: store, onSubmit: onSubmit) + } else { + Button { + onClose() + } label: { + Image(systemName: "checkmark") + .frame(width: iconSize, height: iconSize) + .foregroundStyle(Color.primary) } + .font(.title) + .adaptiveButtonStyle(shape: .circle, color: Color.surface) + } + } + .padding(.horizontal) + .padding(.top, 12) + .overlay { + if !showsEditorActions { + Text(String(localized: "todo_details", bundle: PresentationResources.bundle)) + .font(.title3.bold()) } - .background(Color(.systemGroupedBackground)) - .presentationDragIndicator(.visible) } } - private enum Field: Hashable { - case title, content - } -} - -private struct TodoEditorInfoSheetView: View { - @Bindable var store: StoreOf - let onClose: () -> Void - @FocusState private var isTagFieldFocused: Bool - private let calendar = Calendar.current - - var body: some View { - NavigationStack { - List { - Section(String(localized: "todo_options_section", bundle: PresentationResources.bundle)) { - Picker( - String(localized: "todo_category", bundle: PresentationResources.bundle), - selection: Binding( - get: { store.category.id }, - set: { categoryId in - guard let item = store.categories.first(where: { - $0.id == categoryId - }) else { - return + private var content: some View { + ScrollView { + LazyVStack(spacing: 24) { + VStack(spacing: 0) { + HStack(spacing: 12) { + optionIcon("tag.fill", color: Color.accent) + Spacer() + Picker( + String(localized: "todo_category", bundle: PresentationResources.bundle), + selection: Binding( + get: { store.category.id }, + set: { categoryId in + guard let item = store.categories.first(where: { + $0.id == categoryId + }) else { return } + store.send(.binding(.set(\.category, item))) + } + ) + ) { + ForEach(store.categories, id: \.id) { item in + Text(item.localizedName) + .tag(item.id) } - - store.send(.binding(.set(\.category, item))) } - ) - ) { - ForEach(store.categories, id: \.id) { item in - Text(item.localizedName) - .tag(item.id) + .pickerStyle(.menu) + .tint(Color.accent) } - } - - Toggle( - String(localized: "todo_completed", bundle: PresentationResources.bundle), - isOn: Binding( - get: { store.isCompleted }, - set: { store.send(.setCompleted($0)) } - ) - ) - .tint(.blue) + .padding(.vertical, 12) + + Divider().overlay(Color.border) + + Toggle( + isOn: Binding( + get: { store.isCompleted }, + set: { store.send(.setCompleted($0)) } + ) + ) { + HStack(spacing: 12) { + optionIcon("circle", color: Color.textSecondary) + Text(String(localized: "todo_completed", bundle: PresentationResources.bundle)) + } + } + .tint(Color.accent) + .padding(.vertical, 12) + + Divider().overlay(Color.border) + + Toggle( + isOn: Binding( + get: { store.isPinned }, + set: { store.send(.binding(.set(\.isPinned, $0))) } + ) + ) { + HStack(spacing: 12) { + optionIcon("star.fill", color: Color.warning) + Text(String(localized: "todo_pinned", bundle: PresentationResources.bundle)) + } + } + .tint(Color.accent) + .padding(.vertical, 12) - Toggle( - String(localized: "todo_pinned", bundle: PresentationResources.bundle), - isOn: Binding( - get: { store.isPinned }, - set: { store.send(.binding(.set(\.isPinned, $0))) } - ) - ) - .tint(.blue) + Divider().overlay(Color.border) - dueDateControl - } + dueDateControl + .padding(.vertical, 12) + } + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } - Section(String(localized: "todo_tags", bundle: PresentationResources.bundle)) { - HStack(spacing: 12) { - TextField( - String(localized: "todo_add", bundle: PresentationResources.bundle), - text: $store.tagText - ) - .frame(height: UIFont.preferredFont(forTextStyle: .title2).lineHeight) - .textInputAutocapitalization(.never) - .focused($isTagFieldFocused) - .onSubmit { - submitTag() - } + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "todo_tags", bundle: PresentationResources.bundle)) + .font(.headline) + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + TextField( + String(localized: "todo_add", bundle: PresentationResources.bundle), + text: $store.tagText + ) + .frame(height: UIFont.preferredFont(forTextStyle: .title2).lineHeight) + .textInputAutocapitalization(.never) + .focused($isTagFieldFocused) + .onSubmit { submitTag() } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 12) + .fill(Color.surfaceSecondary) + .strokeBorder(Color.border, lineWidth: 2) + } + .tint(Color.accent) + + Button { + submitTag() + } label: { + Image(systemName: "plus") + .font(.title2.weight(.semibold)) + .foregroundStyle(canSubmitTag ? Color.surface : Color.textSecondary) + .padding(12) + .background( + canSubmitTag ? Color.accent : Color.surfaceSecondary, + in: Circle() + ) + } + .buttonStyle(.plain) + .disabled(!canSubmitTag) + } - if isTagFieldFocused { - Button { - submitTag() - } label: { - Image(systemName: "plus.circle.fill") - .font(.title2) - .foregroundStyle(canSubmitTag ? .blue : .secondary) + if store.tags.isEmpty { + Text(String(localized: "todo_no_tags", bundle: PresentationResources.bundle)) + .foregroundStyle(.secondary) + .padding(.vertical, 4) + } else { + TagList( + store.tags, + isEditing: true, + action: { store.send(.removeTag($0)) } + ) } - .disabled(!canSubmitTag) + Text("같은 태그는 한 번만 추가할 수 있어요") // l10n + } + .padding(20) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) } } - - if store.tags.isEmpty { - Text(String(localized: "todo_no_tags", bundle: PresentationResources.bundle)) - .foregroundStyle(.secondary) - .padding(.vertical, 4) - } else { - TagList( - store.tags, - isEditing: isTagFieldFocused, - action: { store.send(.removeTag($0)) } - ) - } - } - } - .navigationTitle(String(localized: "todo_details", bundle: PresentationResources.bundle)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarLeadingButton { - onClose() } + .padding(.horizontal, 20) + .padding(.bottom, 20) } - } + .contentMargins(.top, 16, for: .scrollContent) + } + + private func optionIcon(_ name: String, color: Color) -> some View { + Image(systemName: name) + .font(.title3) + .foregroundStyle(color) + .frame(width: 44, height: 44) + .background(color.opacity(0.08), in: Circle()) } private var dueDateControl: some View { @@ -399,7 +582,8 @@ private struct TodoEditorInfoSheetView: View { get: { store.dueDate ?? Date() }, set: { store.send(.binding(.set(\.dueDate, $0))) } )) { - HStack { + HStack(spacing: 12) { + optionIcon("calendar", color: Color.textSecondary) Text(String(localized: "todo_due_date", bundle: PresentationResources.bundle)) .foregroundStyle(.primary) Spacer() @@ -416,6 +600,11 @@ private struct TodoEditorInfoSheetView: View { } } + private func close() { + isTagFieldFocused = false + onClose() + } + private func submitTag() { guard canSubmitTag else { return } @@ -490,3 +679,7 @@ private struct DueDatePicker: View { } } } + +private enum Field: Hashable { + case title, content +} diff --git a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift index f757124d..e8b03c68 100644 --- a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift +++ b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift @@ -24,7 +24,8 @@ final class TodoEditorStoreTestAdapter { var content: String { store.state.content } var referenceItems: [Int: TodoReferenceItem] { store.state.referenceItems } var dueDate: Date? { store.state.dueDate } - var sheet: TodoEditorFeature.SheetState? { store.state.sheet } + var inspectorContent: TodoEditorFeature.InspectorContent { store.state.inspectorContent } + var isInspectorPresented: Bool { store.state.isInspectorPresented } var isLoading: Bool { store.state.isLoading } var tags: [String] { Array(store.state.tags) } var categories: [TodoCategoryItem] { store.state.categories } @@ -110,21 +111,16 @@ final class TodoEditorStoreTestAdapter { } } - func setSheet(_ sheet: TodoEditorFeature.SheetState?) async { - await store.send(.setSheet(sheet)) { - $0.sheet = sheet + func showInspector(_ content: TodoEditorFeature.InspectorContent) async { + await store.send(.showInspector(content)) { + $0.inspectorContent = content + $0.isInspectorPresented = true } } - func dismissSheet() async { - await store.send(.sheet(.dismiss)) { - $0.sheet = nil - } - } - - func tapSheetCloseButton() async { - await store.send(.sheet(.presented(.tapCloseButton))) { - $0.sheet = nil + func setInspectorPresented(_ isPresented: Bool) async { + await store.send(.binding(.set(\.isInspectorPresented, isPresented))) { + $0.isInspectorPresented = isPresented } } diff --git a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift index 66fce96e..3687de5d 100644 --- a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift +++ b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift @@ -164,26 +164,33 @@ struct TodoEditorFeatureTests { #expect(adapter.referenceItems[5] == TodoReferenceItem(from: reference5)) } - @Test("정보와 참조 Todo 시트 상태를 액션에 맞게 변경한다") - func 정보와_참조_Todo_시트_상태를_액션에_맞게_변경한다() async { + @Test("하나의 inspector에서 옵션과 참조 Todo를 전환한다") + func 하나의_inspector에서_옵션과_참조_Todo를_전환한다() async { let adapter = TodoEditorStoreTestAdapter(category: .system(.doc)) let item = TodoIdItem(id: "todo-2") - await adapter.setSheet(.info) + await adapter.showInspector(.options) - #expect(adapter.sheet == .info) + #expect(adapter.isInspectorPresented) + #expect(adapter.inspectorContent == .options) - await adapter.dismissSheet() + await adapter.showInspector(.todo(item)) - #expect(adapter.sheet == nil) + #expect(adapter.inspectorContent == .todo(item)) + #expect(adapter.isInspectorPresented) - await adapter.setSheet(.todo(item)) + await adapter.showInspector(.options) - #expect(adapter.sheet == .todo(item)) + #expect(adapter.inspectorContent == .options) + #expect(adapter.isInspectorPresented) - await adapter.tapSheetCloseButton() + await adapter.setInspectorPresented(false) - #expect(adapter.sheet == nil) + #expect(!adapter.isInspectorPresented) + await adapter.showInspector(.todo(item)) + + #expect(adapter.inspectorContent == .todo(item)) + #expect(adapter.isInspectorPresented) } @Test("새 Todo 저장 성공은 draft를 저장하고 생성 delegate를 전송한다") From 5dccb0d0c623b1572969ae7359e6962917cfe243 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 11 Sep 2026 15:22:41 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20TodoEditorView=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=9D=84=20Store=20=EB=B0=94=EC=9D=B8=EB=94=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Todo/Editor/TodoEditorFeature.swift | 12 ++++++- .../Sources/Todo/Editor/TodoEditorView.swift | 31 +++---------------- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift index 6da6cf62..c2ff248f 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift @@ -24,6 +24,10 @@ public struct TodoEditorFeature { public var content: String = "" public var referenceItems: [Int: TodoReferenceItem] = [:] public var dueDate: Date? + public var selectedDueDate: Date { + get { dueDate ?? Date() } + set { dueDate = newValue } + } public var loading = LoadingFeature.State() public var tags: OrderedSet = [] public var tagText: String = "" @@ -150,6 +154,12 @@ public struct TodoEditorFeature { LoadingFeature() } BindingReducer() + .onChange(of: \.isCompleted) { _, isCompleted in + Reduce { state, _ in + state.completedAt = isCompleted ? now : nil + return .none + } + } Reduce { state, action in switch action { case .alert: @@ -158,7 +168,7 @@ public struct TodoEditorFeature { if state.tabViewTag == .preview { return resolveMarkdownEffect(content: state.content) } - case .binding(\.dueDate): + case .binding(\.dueDate), .binding(\.selectedDueDate): if let tomorrowDate = Calendar.current.date(byAdding: .day, value: 1, to: now), let dueDate = state.dueDate { state.dueDate = max(dueDate, tomorrowDate) diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index cdd01589..fa9f8aea 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -439,19 +439,11 @@ private struct InspectorView: View { Spacer() Picker( String(localized: "todo_category", bundle: PresentationResources.bundle), - selection: Binding( - get: { store.category.id }, - set: { categoryId in - guard let item = store.categories.first(where: { - $0.id == categoryId - }) else { return } - store.send(.binding(.set(\.category, item))) - } - ) + selection: $store.category ) { ForEach(store.categories, id: \.id) { item in Text(item.localizedName) - .tag(item.id) + .tag(item) } } .pickerStyle(.menu) @@ -461,12 +453,7 @@ private struct InspectorView: View { Divider().overlay(Color.border) - Toggle( - isOn: Binding( - get: { store.isCompleted }, - set: { store.send(.setCompleted($0)) } - ) - ) { + Toggle(isOn: $store.isCompleted) { HStack(spacing: 12) { optionIcon("circle", color: Color.textSecondary) Text(String(localized: "todo_completed", bundle: PresentationResources.bundle)) @@ -477,12 +464,7 @@ private struct InspectorView: View { Divider().overlay(Color.border) - Toggle( - isOn: Binding( - get: { store.isPinned }, - set: { store.send(.binding(.set(\.isPinned, $0))) } - ) - ) { + Toggle(isOn: $store.isPinned) { HStack(spacing: 12) { optionIcon("star.fill", color: Color.warning) Text(String(localized: "todo_pinned", bundle: PresentationResources.bundle)) @@ -578,10 +560,7 @@ private struct InspectorView: View { } private var dueDateControl: some View { - DueDatePicker(selection: Binding( - get: { store.dueDate ?? Date() }, - set: { store.send(.binding(.set(\.dueDate, $0))) } - )) { + DueDatePicker(selection: $store.selectedDueDate) { HStack(spacing: 12) { optionIcon("calendar", color: Color.textSecondary) Text(String(localized: "todo_due_date", bundle: PresentationResources.bundle)) From 6e804c8fb4602ab55ad3e754927e62b233baa6c5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 11 Sep 2026 16:14:34 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20Widget=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=83=89=EC=83=81=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/App/Project.swift | 2 +- Application/Presentation/Project.swift | 1 + .../AccentColor.colorset/Contents.json | 0 .../AppBackground.colorset/Contents.json | 0 .../ColorPalette.xcassets}/Border.colorset/Contents.json | 0 .../Shared/Resources/ColorPalette.xcassets/Contents.json | 6 ++++++ .../ColorPalette.xcassets}/Danger.colorset/Contents.json | 0 .../DangerContainer.colorset/Contents.json | 0 .../ColorPalette.xcassets}/Info.colorset/Contents.json | 0 .../InfoContainer.colorset/Contents.json | 0 .../OnPrimaryContainer.colorset/Contents.json | 0 .../PrimaryContainer.colorset/Contents.json | 0 .../ColorPalette.xcassets}/Success.colorset/Contents.json | 0 .../SuccessContainer.colorset/Contents.json | 0 .../ColorPalette.xcassets}/Surface.colorset/Contents.json | 0 .../SurfaceSecondary.colorset/Contents.json | 0 .../TextSecondary.colorset/Contents.json | 0 .../TextTertiary.colorset/Contents.json | 0 .../ColorPalette.xcassets}/Warning.colorset/Contents.json | 0 .../WarningContainer.colorset/Contents.json | 0 Widget/WidgetExtension/Project.swift | 2 +- 21 files changed, 9 insertions(+), 2 deletions(-) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/AccentColor.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/AppBackground.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/Border.colorset/Contents.json (100%) create mode 100644 Application/Shared/Resources/ColorPalette.xcassets/Contents.json rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/Danger.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/DangerContainer.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/Info.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/InfoContainer.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/OnPrimaryContainer.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/PrimaryContainer.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/Success.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/SuccessContainer.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/Surface.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/SurfaceSecondary.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/TextSecondary.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/TextTertiary.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/Warning.colorset/Contents.json (100%) rename Application/{Presentation/PresentationShared/Resources/Assets.xcassets => Shared/Resources/ColorPalette.xcassets}/WarningContainer.colorset/Contents.json (100%) diff --git a/Application/App/Project.swift b/Application/App/Project.swift index 76eb7249..79533568 100644 --- a/Application/App/Project.swift +++ b/Application/App/Project.swift @@ -20,7 +20,7 @@ let project = Project( sources: ["Sources/**/*.swift"], resources: [ "Sources/Resource/Assets.xcassets", - "../Presentation/PresentationShared/Resources/Assets.xcassets", + "../Shared/Resources/ColorPalette.xcassets", "Sources/Resource/GoogleService-Info.plist", "../Presentation/PresentationShared/Resources/Localizable.xcstrings", ], diff --git a/Application/Presentation/Project.swift b/Application/Presentation/Project.swift index 354ab209..6b909831 100644 --- a/Application/Presentation/Project.swift +++ b/Application/Presentation/Project.swift @@ -42,6 +42,7 @@ let project = Project( sources: ["PresentationShared/Sources/**/*.swift"], resources: [ "PresentationShared/Resources/Assets.xcassets", + "../Shared/Resources/ColorPalette.xcassets", "PresentationShared/Resources/Localizable.xcstrings", ], scripts: [ diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/AccentColor.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/AccentColor.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/AppBackground.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/AppBackground.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/AppBackground.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/AppBackground.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/Border.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/Border.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/Border.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/Border.colorset/Contents.json diff --git a/Application/Shared/Resources/ColorPalette.xcassets/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Application/Shared/Resources/ColorPalette.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/Danger.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/Danger.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/Danger.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/Danger.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/DangerContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/DangerContainer.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/DangerContainer.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/DangerContainer.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/Info.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/Info.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/Info.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/Info.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/InfoContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/InfoContainer.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/InfoContainer.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/InfoContainer.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/OnPrimaryContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/OnPrimaryContainer.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/OnPrimaryContainer.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/OnPrimaryContainer.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/PrimaryContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/PrimaryContainer.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/PrimaryContainer.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/PrimaryContainer.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/Success.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/Success.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/Success.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/Success.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/SuccessContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/SuccessContainer.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/SuccessContainer.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/SuccessContainer.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/Surface.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/Surface.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/Surface.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/Surface.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/SurfaceSecondary.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/SurfaceSecondary.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/SurfaceSecondary.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/SurfaceSecondary.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/TextSecondary.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/TextSecondary.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/TextSecondary.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/TextSecondary.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/TextTertiary.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/TextTertiary.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/TextTertiary.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/TextTertiary.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/Warning.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/Warning.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/Warning.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/Warning.colorset/Contents.json diff --git a/Application/Presentation/PresentationShared/Resources/Assets.xcassets/WarningContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/WarningContainer.colorset/Contents.json similarity index 100% rename from Application/Presentation/PresentationShared/Resources/Assets.xcassets/WarningContainer.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/WarningContainer.colorset/Contents.json diff --git a/Widget/WidgetExtension/Project.swift b/Widget/WidgetExtension/Project.swift index 55f45920..125eb268 100644 --- a/Widget/WidgetExtension/Project.swift +++ b/Widget/WidgetExtension/Project.swift @@ -26,7 +26,7 @@ let project = Project( ], resources: [ "Resource/Assets.xcassets", - "../../Application/Presentation/PresentationShared/Resources/Assets.xcassets", + "../../Application/Shared/Resources/ColorPalette.xcassets", "Resource/Localizable.xcstrings" ], entitlements: .file(path: "Resource/DevLogWidget.entitlements"), From 644c5c9409f02cf32ec1dd48585db89f3cd0338a Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 11 Sep 2026 16:15:03 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=ED=83=9C=EA=B7=B8=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=95=88=EB=82=B4=20=ED=98=84=EC=A7=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/Localizable.xcstrings | 17 +++++++++++++++++ .../Sources/Todo/Editor/TodoEditorView.swift | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 8e4dd417..8d930fde 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -3428,6 +3428,23 @@ } } }, + "todo_tag_duplicate_hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Each tag can only be added once." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "같은 태그는 한 번만 추가할 수 있어요." + } + } + } + }, "todo_tags" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index fa9f8aea..7301980a 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -535,7 +535,10 @@ private struct InspectorView: View { action: { store.send(.removeTag($0)) } ) } - Text("같은 태그는 한 번만 추가할 수 있어요") // l10n + Text(String( + localized: "todo_tag_duplicate_hint", + bundle: PresentationResources.bundle + )) } .padding(20) .background { From 9b113ecbba250f60b860f751276834593c407d7d Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 11 Sep 2026 16:15:17 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20Todo=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=84=A0=ED=83=9D=20ID=20=EB=B0=94=EC=9D=B8?= =?UTF-8?q?=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Todo/Editor/TodoEditorFeature.swift | 7 +++++ .../Sources/Todo/Editor/TodoEditorView.swift | 4 +-- .../Todo/TodoEditorFeatureTestDoubles.swift | 7 +++++ .../Tests/Todo/TodoEditorFeatureTests.swift | 26 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift index c2ff248f..67b3a63f 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift @@ -35,6 +35,13 @@ public struct TodoEditorFeature { public var editorHeaderHeight = CGFloat.zero public var categories: [TodoCategoryItem] = [] public var category = TodoCategoryItem(from: .system(.etc)) + public var selectedCategoryID: String { + get { category.id } + set { + guard let category = categories.first(where: { $0.id == newValue }) else { return } + self.category = category + } + } public var saveResult: SaveResult? let id: String let isChecked: Bool diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index 7301980a..aaad1969 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -439,11 +439,11 @@ private struct InspectorView: View { Spacer() Picker( String(localized: "todo_category", bundle: PresentationResources.bundle), - selection: $store.category + selection: $store.selectedCategoryID ) { ForEach(store.categories, id: \.id) { item in Text(item.localizedName) - .tag(item) + .tag(item.id) } } .pickerStyle(.menu) diff --git a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift index e8b03c68..c154b912 100644 --- a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift +++ b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift @@ -30,6 +30,7 @@ final class TodoEditorStoreTestAdapter { var tags: [String] { Array(store.state.tags) } var categories: [TodoCategoryItem] { store.state.categories } var category: TodoCategoryItem { store.state.category } + var selectedCategoryID: String { store.state.selectedCategoryID } var hasChanges: Bool { store.state.hasChanges } var isReadyToSubmit: Bool { store.state.isReadyToSubmit } var saveResult: TodoEditorFeature.SaveResult? { store.state.saveResult } @@ -137,6 +138,12 @@ final class TodoEditorStoreTestAdapter { } } + func setSelectedCategoryID(_ id: String) async { + await store.send(.binding(.set(\.selectedCategoryID, id))) { + $0.selectedCategoryID = id + } + } + func setTab(_ tab: TodoEditorFeature.EditorTab) async { await store.send(.binding(.set(\.tabViewTag, tab))) { $0.tabViewTag = tab diff --git a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift index 3687de5d..6a2c8535 100644 --- a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift +++ b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift @@ -97,6 +97,32 @@ struct TodoEditorFeatureTests { ]) } + @Test("숨김 카테고리 Todo도 ID로 선택 상태를 유지한다") + func 숨김_카테고리_Todo도_ID로_선택_상태를_유지한다() async { + let visiblePreference = TodoCategoryPreference(category: .system(.doc), isVisible: true) + let hiddenPreference = TodoCategoryPreference(category: .system(.issue), isVisible: false) + let adapter = TodoEditorStoreTestAdapter( + todo: makeTodoEditorTodo(category: .system(.issue)), + fetchPreferencesUseCase: TodoEditorFetchPreferencesUseCaseSpy( + preferences: [visiblePreference, hiddenPreference] + ) + ) + + await adapter.onAppear() + + #expect(adapter.selectedCategoryID == TodoCategoryItem(from: hiddenPreference).id) + #expect(adapter.categories.contains { $0.id == adapter.selectedCategoryID }) + + await adapter.setSelectedCategoryID(TodoCategoryItem(from: visiblePreference).id) + #expect(adapter.category == TodoCategoryItem(from: visiblePreference)) + + await adapter.setSelectedCategoryID(TodoCategoryItem(from: hiddenPreference).id) + #expect(adapter.category == TodoCategoryItem(from: hiddenPreference)) + + await adapter.setSelectedCategoryID("missing-category") + #expect(adapter.category == TodoCategoryItem(from: hiddenPreference)) + } + @Test("태그 추가와 삭제는 OrderedSet 상태를 변경한다") func 태그_추가와_삭제는_OrderedSet_상태를_변경한다() async { let adapter = TodoEditorStoreTestAdapter(category: .system(.doc))