diff --git a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift index 60149c9..397a508 100644 --- a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift @@ -16,6 +16,7 @@ final class TodoEditorViewModel: Store { let content: String let dueDate: Date? let tags: [String] + let kind: TodoKind init(todo: Todo) { self.isPinned = todo.isPinned @@ -23,6 +24,7 @@ final class TodoEditorViewModel: Store { self.content = todo.content self.dueDate = todo.dueDate self.tags = todo.tags + self.kind = todo.kind } init(state: State) { @@ -31,6 +33,7 @@ final class TodoEditorViewModel: Store { self.content = state.content self.dueDate = state.dueDate self.tags = Array(state.tags) + self.kind = state.kind } } @@ -39,10 +42,12 @@ final class TodoEditorViewModel: Store { var title: String = "" var content: String = "" var dueDate: Date? + var showInfo: Bool = false var tags: OrderedSet = [] var tagText: String = "" var focusOnEditor: Bool = false var tabViewTag: Tag = .editor + var kind: TodoKind = .etc var isValidToSave: Bool { !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -57,25 +62,33 @@ final class TodoEditorViewModel: Store { case removeTag(String) case setContent(String) case setDueDate(Date?) + case setKind(TodoKind) + case setPinned(Bool) + case setShowInfo(Bool) case setTabViewTag(Tag) case setTagText(String) case setTitle(String) - case togglePinned } enum SideEffect { } private(set) var state = State() private let calendar = Calendar.current - let navigationTitle: String private let id: String private let isCompleted: Bool private let isChecked: Bool private let createdAt: Date? private let completedAt: Date? - private let kind: TodoKind private let originalDraft: Draft? + var navigationTitle: String { + if originalDraft == nil { + return "새 \(state.kind.localizedName) 추가" + } + + return "편집" + } + var hasChanges: Bool { guard let originalDraft else { return true } return originalDraft != Draft(state: state) @@ -87,31 +100,29 @@ final class TodoEditorViewModel: Store { // 새로운 Todo 생성용 생성자 init(kind: TodoKind) { - self.navigationTitle = "새 \(kind.localizedName) 추가" self.id = UUID().uuidString self.isCompleted = false self.isChecked = false self.createdAt = nil self.completedAt = nil - self.kind = kind self.originalDraft = nil + state.kind = kind } // 기존 Todo 편집용 생성자 init(todo: Todo) { - self.navigationTitle = "편집" self.id = todo.id self.isCompleted = todo.isCompleted self.isChecked = todo.isChecked self.createdAt = todo.createdAt self.completedAt = todo.completedAt - self.kind = todo.kind self.originalDraft = Draft(todo: todo) state.isPinned = todo.isPinned state.title = todo.title state.content = todo.content state.dueDate = todo.dueDate state.tags = OrderedSet(todo.tags) + state.kind = todo.kind } func reduce(with action: Action) -> [SideEffect] { @@ -134,10 +145,14 @@ final class TodoEditorViewModel: Store { } else { state.dueDate = nil } + case .setKind(let todoKind): + state.kind = todoKind + case .setPinned(let isPinned): + state.isPinned = isPinned + case .setShowInfo(let isPresented): + state.showInfo = isPresented case .setTabViewTag(let tag): state.tabViewTag = tag - case .togglePinned: - state.isPinned.toggle() } if self.state != state { self.state = state } @@ -177,7 +192,7 @@ extension TodoEditorViewModel { completedAt: self.completedAt, dueDate: state.dueDate, tags: state.tags.map { $0 }, - kind: self.kind + kind: state.kind ) } } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 8c68d1c..f94a144 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -332,6 +332,9 @@ }, "설정에서의 푸시 알람 설정과 별개입니다." : { + }, + "세부 정보" : { + }, "소셜 계정" : { @@ -347,6 +350,9 @@ }, "알림" : { + }, + "없음" : { + }, "연결" : { @@ -359,6 +365,9 @@ }, "오늘" : { + }, + "옵션" : { + }, "완료" : { @@ -414,9 +423,6 @@ }, "주차" : { - }, - "중요" : { - }, "중요 표시" : { @@ -441,6 +447,9 @@ }, "취소" : { + }, + "카테고리" : { + }, "컨텐츠" : { @@ -451,7 +460,7 @@ "태그" : { }, - "태그 입력" : { + "태그 없음" : { }, "테마" : { diff --git a/DevLog/UI/Common/Component/Tag+.swift b/DevLog/UI/Common/Component/Tag+.swift index 0e71cfc..3c47488 100644 --- a/DevLog/UI/Common/Component/Tag+.swift +++ b/DevLog/UI/Common/Component/Tag+.swift @@ -54,6 +54,7 @@ struct Tag: View { ) } + .buttonStyle(.plain) } } .background { diff --git a/DevLog/UI/Home/TodoEditorView.swift b/DevLog/UI/Home/TodoEditorView.swift index f7126e3..f8534a1 100644 --- a/DevLog/UI/Home/TodoEditorView.swift +++ b/DevLog/UI/Home/TodoEditorView.swift @@ -11,44 +11,52 @@ import SwiftUI struct TodoEditorView: View { @State var viewModel: TodoEditorViewModel - @Environment(\.safeAreaInsets) private var safeAreaInsets @Environment(\.dismiss) private var dismiss @FocusState private var field: Field? - @State private var showDueDatePicker: Bool = false private let calendar = Calendar.current var onSubmit: ((Todo) -> Void)? var body: some View { NavigationStack { - ZStack(alignment: .bottom) { - ScrollView { - LazyVStack(spacing: 10) { - titleField - LazyVStack( - alignment: .leading, - spacing: 0, - pinnedViews: [.sectionHeaders] - ) { - Section { - tabView - } header: { - tabViewSelector - } + ScrollView { + LazyVStack(spacing: 10) { + titleField + LazyVStack( + alignment: .leading, + spacing: 0, + pinnedViews: [.sectionHeaders] + ) { + Section { + tabView + } header: { + tabViewSelector } } } - .onTapGesture { - field = .description - } - accessoryBar - .padding(.horizontal) - .padding(.bottom, 16 + safeAreaInsets.bottom / 4) + } + .onTapGesture { + field = .content } .navigationTitle(viewModel.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.background, for: .navigationBar) + .sheet(isPresented: Binding( + get: { viewModel.state.showInfo }, + set: { viewModel.send(.setShowInfo($0)) } + )) { + TodoEditorInfoSheetView(viewModel: viewModel) { + viewModel.send(.setShowInfo(false)) + } + } .toolbar { ToolbarLeadingButton { dismiss() } + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel.send(.setShowInfo(true)) + } label: { + Image(systemName: "info.circle") + } + } ToolbarTrailingButton { submit() } @@ -77,7 +85,7 @@ struct TodoEditorView: View { HStack(spacing: 0) { Button(action: { viewModel.send(.setTabViewTag(.editor)) - field = .description + field = .content }) { Text("편집") .frame(maxWidth: .infinity) @@ -117,7 +125,7 @@ struct TodoEditorView: View { axis: .vertical ) .font(.callout) - .focused($field, equals: .description) + .focused($field, equals: .content) } } else { if viewModel.state.content.isEmpty { @@ -150,33 +158,100 @@ struct TodoEditorView: View { .padding(.vertical, 8) } - private var accessoryBar: some View { - HStack { - Button { - viewModel.send(.togglePinned) - } label: { - Label { - Text("중요") - } icon: { - Image(systemName: viewModel.state.isPinned ? "star.fill" : "star") - .foregroundStyle(viewModel.state.isPinned ? .yellow : .gray) + private func submit() { + let todo = viewModel.makeTodo() + onSubmit?(todo) + dismiss() + } + + private enum Field: Hashable { + case title, content + } +} + +private struct TodoEditorInfoSheetView: View { + @Bindable var viewModel: TodoEditorViewModel + let onClose: () -> Void + @FocusState private var isTagFieldFocused: Bool + private let calendar = Calendar.current + + var body: some View { + NavigationStack { + List { + Section("옵션") { + Picker( + "카테고리", + selection: Binding( + get: { viewModel.state.kind }, + set: { viewModel.send(.setKind($0)) } + ) + ) { + ForEach(TodoKind.allCases) { todoKind in + Text(todoKind.localizedName) + .tag(todoKind) + } + } + + Toggle( + "중요 표시", + isOn: Binding( + get: { viewModel.state.isPinned }, + set: { viewModel.send(.setPinned($0)) } + ) + ) + .tint(.blue) + + dueDateControl + } + + Section("태그") { + HStack(spacing: 12) { + TextField( + "추가", + text: Binding( + get: { viewModel.state.tagText }, + set: { viewModel.send(.setTagText($0)) } + ) + ) + .frame(height: UIFont.preferredFont(forTextStyle: .title2).lineHeight) + .textInputAutocapitalization(.never) + .focused($isTagFieldFocused) + .onSubmit { + submitTag() + } + + if isTagFieldFocused { + Button { + submitTag() + } label: { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundStyle(canSubmitTag ? .blue : .secondary) + } + .disabled(!canSubmitTag) + } + } + + if viewModel.state.tags.isEmpty { + Text("태그 없음") + .foregroundStyle(.secondary) + .padding(.vertical, 4) + } else { + TagList( + viewModel.state.tags, + isEditing: isTagFieldFocused, + action: { viewModel.send(.removeTag($0)) } + ) + } } - .adaptiveButtonStyle() } - TagEditor( - tags: viewModel.state.tags, - addAction: { viewModel.send(.addTag($0)) }, - deleteAction: { viewModel.send(.removeTag($0)) } - ) { - Label { - Text("태그") - } icon: { - Image(systemName: "tag") - .foregroundStyle(.gray) + .navigationTitle("세부 정보") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarLeadingButton { + onClose() } - .adaptiveButtonStyle() } - dueDateControl } } @@ -185,31 +260,39 @@ struct TodoEditorView: View { get: { viewModel.state.dueDate ?? Date() }, set: { viewModel.send(.setDueDate($0)) } )) { - Label { + HStack { + Text("마감일") + .foregroundStyle(.primary) + + Spacer() + if let dueDate = viewModel.state.dueDate { Tag(dueDateText(for: dueDate), isEditing: true) { viewModel.send(.setDueDate(nil)) } .padding(.vertical, -4) } else { - Text("마감일") + Text("없음") + .foregroundStyle(.secondary) } - } icon: { - Image(systemName: "calendar") - .foregroundStyle(.gray) } } - .adaptiveButtonStyle() } - private func submit() { - let todo = viewModel.makeTodo() - onSubmit?(todo) - dismiss() + private func submitTag() { + guard canSubmitTag else { return } + + let tagText = normalizedTagText + viewModel.send(.addTag(tagText)) + viewModel.send(.setTagText("")) } - private enum Field: Hashable { - case title, description, tag + private var normalizedTagText: String { + viewModel.state.tagText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmitTag: Bool { + !normalizedTagText.isEmpty && !viewModel.state.tags.contains(normalizedTagText) } private func dueDateText(for dueDate: Date) -> String { @@ -228,135 +311,6 @@ struct TodoEditorView: View { } } -private struct TagEditor: View { - @Environment(\.safeAreaInsets) private var safeAreaInsets - @State private var isPresented: Bool = false - @State private var sheetHeight: CGFloat = .pi - @State private var tagsHeight: CGFloat = 0 - @State private var fieldHeight: CGFloat = 0 - @State private var tag = "" - @ViewBuilder private var content: () -> Content - private let tags: OrderedSet - private let addAction: (String) -> Void - private let deleteAction: (String) -> Void - private let spacing: CGFloat = 8 - - init( - tags: OrderedSet, - addAction: @escaping (String) -> Void = { _ in }, - deleteAction: @escaping (String) -> Void = { _ in }, - @ViewBuilder content: @escaping () -> Content - ) { - self.tags = tags - self.addAction = addAction - self.deleteAction = deleteAction - self.content = content - } - - var body: some View { - Button { - isPresented = true - } label: { - content() - } - .sheet( - isPresented: $isPresented, - onDismiss: { tag = "" } - ) { - VStack(spacing: tags.isEmpty ? 0 : spacing) { - ScrollView { - TagList(tags, isEditing: true, action: deleteAction) - .background { - GeometryReader { geometry in - Color.clear - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - tagsHeight = geometry.size.height - sheetHeight += tagsHeight + (tagsHeight == 0 ? 0 : spacing) - } - } - .onChange(of: tags) { _, newTags in - DispatchQueue.main.async { - tagsHeight = geometry.size.height - sheetHeight = fieldHeight + tagsHeight + (newTags.isEmpty ? 0 : spacing) - } - } - } - } - } - .scrollIndicators(.hidden) - .frame(maxHeight: tagsHeight) - .padding(.top, tags.isEmpty ? 0 : 8) - - tagField - .background { - GeometryReader { geometry in - Color.clear - .onAppear { - fieldHeight = geometry.size.height + 16 - sheetHeight = fieldHeight - } - } - } - - } - .padding(.horizontal) - .presentationDragIndicator(.hidden) - .presentationDetents([.height(sheetHeight)]) - } - } - - private var tagField: some View { - HStack { - HStack { - TextField("태그 입력", text: $tag) - .keyboardType(.webSearch) - .padding(tag.isEmpty ? .all : [.leading, .vertical]) - .onSubmit { - isPresented = false - } - - if !tag.isEmpty { - Button { - tag = "" - } label: { - Image(systemName: "xmark.circle.fill") - .font(.title) - .symbolRenderingMode(.palette) - .foregroundStyle( - Color(.label), - Color(.systemBackground) - ) - } - .padding(.trailing) - } - } - .background { - Capsule() - .fill(.ultraThinMaterial) - .overlay { - Capsule() - .stroke(Color.white.opacity(0.2), lineWidth: 1) - } - } - - Button { - addAction(tag) - tag = "" - } label: { - Image(systemName: "plus") - .font(.largeTitle) - .foregroundStyle(Color.white) - .adaptiveButtonStyle( - shape: .circle, - color: (!tag.isEmpty && !tags.contains(tag)) ? Color.blue : .gray.opacity(0.4) - ) - } - .disabled(tag.isEmpty || tags.contains(tag)) - } - } -} - private struct DueDatePicker: View { @Environment(\.safeAreaInsets) private var safeAreaInsets @State private var isPresented: Bool = false