diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 639d4b7c..b283b69f 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -27,7 +27,10 @@ private extension AppGraph { &dependencies, fetchGoalUseCase: developmentGraphSet .developmentGoalUseCaseGraph - .fetchDevelopmentGoalUseCase + .fetchDevelopmentGoalUseCase, + updateGoalStatusUseCase: developmentGraphSet + .developmentGoalUseCaseGraph + .updateDevelopmentGoalStatusUseCase ) DevelopmentDependencyPreparation.prepareQuery( &dependencies, diff --git a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift index e0208c85..07242f5b 100644 --- a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift +++ b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift @@ -11,9 +11,11 @@ import PresentationShared public enum DevelopmentDependencyPreparation { public static func prepareGoal( _ dependencies: inout DependencyValues, - fetchGoalUseCase: FetchDevelopmentGoalUseCase + fetchGoalUseCase: FetchDevelopmentGoalUseCase, + updateGoalStatusUseCase: UpdateDevelopmentGoalStatusUseCase ) { dependencies.developmentFetchGoalUseCase = fetchGoalUseCase + dependencies.developmentUpdateGoalStatusUseCase = updateGoalStatusUseCase } public static func prepareQuery( @@ -52,6 +54,11 @@ extension DependencyValues { set { self[DevelopmentFetchRecordsUseCaseKey.self] = newValue } } + var developmentUpdateGoalStatusUseCase: UpdateDevelopmentGoalStatusUseCase { + get { self[DevelopmentUpdateGoalStatusUseCaseKey.self] } + set { self[DevelopmentUpdateGoalStatusUseCaseKey.self] = newValue } + } + var developmentFetchRecordHistoryUseCase: FetchDevelopmentRecordHistoryUseCase { get { self[DevelopmentFetchRecordHistoryUseCaseKey.self] } set { self[DevelopmentFetchRecordHistoryUseCaseKey.self] = newValue } @@ -95,6 +102,12 @@ private enum DevelopmentFetchRecordsUseCaseKey: DependencyKey { } } +private enum DevelopmentUpdateGoalStatusUseCaseKey: DependencyKey { + static var liveValue: UpdateDevelopmentGoalStatusUseCase { + preconditionFailure("UpdateDevelopmentGoalStatusUseCase must be provided.") + } +} + private enum DevelopmentFetchRecordHistoryUseCaseKey: DependencyKey { static var liveValue: FetchDevelopmentRecordHistoryUseCase { preconditionFailure("FetchDevelopmentRecordHistoryUseCase must be provided.") diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift index 8a787dff..75eb028d 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift @@ -14,6 +14,7 @@ struct RecordDetailFeature { struct State: Equatable { @Presents var alert: AlertState? let goalTitle: String + let allowsMutation: Bool var record: DevelopmentRecord var versions = [DevelopmentRecord.Version]() var contentState: ContentState @@ -30,8 +31,13 @@ struct RecordDetailFeature { versions.first { $0.id == currentVersionID } } - init(goalTitle: String, record: DevelopmentRecord) { + init( + goalTitle: String, + record: DevelopmentRecord, + allowsMutation: Bool = true + ) { self.goalTitle = goalTitle + self.allowsMutation = allowsMutation self.record = record self.currentVersionID = record.currentVersion?.id if record.currentVersion == nil, let draft = record.draft { @@ -88,6 +94,7 @@ struct RecordDetailFeature { case .alert(.presented(.confirmRestore(let version))): state.alert = nil guard !state.isRestoring, + state.allowsMutation, state.record.draft == nil, version.id != state.currentVersionID else { break } let request = state.restoreRequest?.sourceVersionID == version.id @@ -117,6 +124,7 @@ struct RecordDetailFeature { return fetchEffect(goalID: state.record.goalId, recordID: state.record.id) case .view(.restore(let version)): guard !state.isRestoring, + state.allowsMutation, state.record.draft == nil, version.id != state.currentVersionID else { break } state.alert = Self.restoreConfirmationAlert(version) diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift index 1e53b5fd..8c6d0da8 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift @@ -19,13 +19,15 @@ public struct RecordDetailView: View { public init( goalTitle: String, record: DevelopmentRecord, + allowsMutation: Bool = true, onUpdate: @escaping () -> Void = { } ) { self.onUpdate = onUpdate self._store = State(initialValue: Store( initialState: RecordDetailFeature.State( goalTitle: goalTitle, - record: record + record: record, + allowsMutation: allowsMutation ) ) { RecordDetailFeature() @@ -49,7 +51,11 @@ public struct RecordDetailView: View { markdownContent: version.markdownContent, version: version ) - actionSection + if store.allowsMutation { + actionSection + } else { + historyButton + } } case .failed: failureContent diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift index 2e95f33e..edc38c8c 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift @@ -14,52 +14,80 @@ struct RecordTimelineItem: Equatable, Identifiable { let currentVersion: DevelopmentRecord.Version? var id: String { record.id } - var title: String { currentVersion?.title ?? record.draft?.title ?? "" } - var isDraft: Bool { currentVersion == nil } - var versionNumber: Int? { currentVersion?.number } - var date: Date { currentVersion?.confirmedAt ?? record.draft?.updatedAt ?? record.createdAt } + var title: String { record.draft?.title ?? currentVersion?.title ?? "" } + var hasDraft: Bool { record.draft != nil } + var isUnconfirmed: Bool { currentVersion == nil } + var versionNumber: Int? { hasDraft ? nil : currentVersion?.number } + var date: Date { record.draft?.updatedAt ?? currentVersion?.confirmedAt ?? record.createdAt } } @Reducer struct GoalDetailFeature { @ObservableState struct State: Equatable { - @Presents var alert: AlertState? + @Presents var alert: AlertState? let goalId: String - var goalTitle = "" + var goal: DevelopmentGoal? + var updatedGoalStatus: DevelopmentGoal.Status? var items = [RecordTimelineItem]() var isLoading = false + var isTransitioning = false var hasLoaded = false var hasLoadFailure = false + var goalTitle: String { + goal?.title ?? "" + } + + var goalStatus: DevelopmentGoal.Status? { + updatedGoalStatus ?? goal?.status + } + + var allowsRecordMutation: Bool { + goalStatus == .inProgress + } + init(goalId: String) { self.goalId = goalId } } enum Action: Equatable { - case alert(PresentationAction) + case alert(PresentationAction) case view(ViewAction) case store(StoreAction) + enum Alert: Equatable { + case confirmTransition(DevelopmentGoal.Status) + } + enum ViewAction: Equatable { case fetch case refresh + case selectStatus(DevelopmentGoal.Status) } enum StoreAction: Equatable { - case loaded(goalTitle: String, items: [RecordTimelineItem]) + case loaded(goal: DevelopmentGoal, items: [RecordTimelineItem]) + case transitioned(DevelopmentGoal.Status) case failed + case transitionFailed } } @Dependency(\.developmentFetchGoalUseCase) private var fetchGoalUseCase @Dependency(\.developmentFetchRecordsUseCase) private var fetchRecordsUseCase @Dependency(\.developmentFetchRecordVersionUseCase) private var fetchRecordVersionUseCase + @Dependency(\.developmentUpdateGoalStatusUseCase) private var updateGoalStatusUseCase var body: some ReducerOf { Reduce { state, action in switch action { + case .alert(.presented(.confirmTransition(let status))): + guard !state.isTransitioning else { break } + state.alert = nil + state.isTransitioning = true + return transitionEffect(goalId: state.goalId, status: status) case .alert: break case .view(.fetch): @@ -72,16 +100,34 @@ struct GoalDetailFeature { state.isLoading = true state.hasLoadFailure = false return fetchEffect(goalId: state.goalId) - case .store(.loaded(let goalTitle, let items)): - state.goalTitle = goalTitle + case .view(.selectStatus(let status)): + guard let goalStatus = state.goalStatus, + !state.isLoading, + !state.isTransitioning, + Self.canTransition(from: goalStatus, to: status) else { break } + if status == .completed, + let alert = Self.completionBlockingAlert(items: state.items) { + state.alert = alert + } else { + state.alert = Self.transitionConfirmationAlert(status) + } + case .store(.loaded(let goal, let items)): + state.goal = goal + state.updatedGoalStatus = nil state.items = items state.isLoading = false state.hasLoaded = true state.hasLoadFailure = false + case .store(.transitioned(let status)): + state.updatedGoalStatus = status + state.isTransitioning = false case .store(.failed): state.isLoading = false state.hasLoadFailure = true state.alert = Self.errorAlert + case .store(.transitionFailed): + state.isTransitioning = false + state.alert = Self.transitionErrorAlert } return .none @@ -129,19 +175,111 @@ extension GoalDetailFeature { ) } - await send(.store(.loaded(goalTitle: goal.title, items: items))) + await send(.store(.loaded(goal: goal, items: items))) } catch { await send(.store(.failed)) } } } + func transitionEffect( + goalId: String, + status: DevelopmentGoal.Status + ) -> Effect { + .run { [updateGoalStatusUseCase] send in + do { + try await updateGoalStatusUseCase.execute(goalId, to: status) + await send(.store(.transitioned(status))) + } catch { + await send(.store(.transitionFailed)) + } + } + } + static func precedes(_ lhs: DevelopmentRecord, _ rhs: DevelopmentRecord) -> Bool { if lhs.createdAt == rhs.createdAt { return lhs.id < rhs.id } return lhs.createdAt < rhs.createdAt } - static var errorAlert: AlertState { + static func canTransition( + from currentStatus: DevelopmentGoal.Status, + to status: DevelopmentGoal.Status + ) -> Bool { + switch (currentStatus, status) { + case (.inProgress, .completed), + (.inProgress, .archived), + (.completed, .inProgress), + (.archived, .inProgress): + true + default: + false + } + } + + static func completionBlockingAlert( + items: [RecordTimelineItem] + ) -> AlertState? { + guard !items.isEmpty else { + return informationAlert( + titleKey: "development_goal_completion_record_required_title", + messageKey: "development_goal_completion_record_required_message" + ) + } + guard items.last?.isUnconfirmed == false else { + return informationAlert( + titleKey: "development_goal_completion_version_required_title", + messageKey: "development_goal_completion_version_required_message" + ) + } + guard !items.contains(where: \.hasDraft) else { + return informationAlert( + titleKey: "development_goal_completion_draft_title", + messageKey: "development_goal_completion_draft_message" + ) + } + return nil + } + + static func transitionConfirmationAlert( + _ status: DevelopmentGoal.Status + ) -> AlertState { + let keys: (title: String.LocalizationValue, message: String.LocalizationValue) + switch status { + case .inProgress: + keys = ( + "development_goal_resume_alert_title", + "development_goal_resume_alert_message" + ) + case .completed: + keys = ( + "development_goal_complete_alert_title", + "development_goal_complete_alert_message" + ) + case .archived: + keys = ( + "development_goal_archive_alert_title", + "development_goal_archive_alert_message" + ) + } + + return AlertState { + TextState(String(localized: keys.title, bundle: PresentationResources.bundle)) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_cancel", bundle: PresentationResources.bundle)) + } + ButtonState(action: .confirmTransition(status)) { + TextState(String( + localized: transitionActionKey(status), + bundle: PresentationResources.bundle + )) + } + } message: { + TextState(String(localized: keys.message, bundle: PresentationResources.bundle)) + } + } + + static var errorAlert: AlertState { AlertState { TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) } actions: { @@ -155,4 +293,39 @@ extension GoalDetailFeature { )) } } + + static var transitionErrorAlert: AlertState { + informationAlert( + titleKey: "common_error_title", + messageKey: "development_goal_transition_error_message" + ) + } + + static func transitionActionKey( + _ status: DevelopmentGoal.Status + ) -> String.LocalizationValue { + switch status { + case .inProgress: + "development_goal_resume" + case .completed: + "development_goal_complete" + case .archived: + "development_goal_archive" + } + } + + static func informationAlert( + titleKey: String.LocalizationValue, + messageKey: String.LocalizationValue + ) -> AlertState { + AlertState { + TextState(String(localized: titleKey, bundle: PresentationResources.bundle)) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String(localized: messageKey, bundle: PresentationResources.bundle)) + } + } } diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index 2fb1fbe2..e3c9c3c8 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -10,6 +10,7 @@ import Domain import PresentationShared public struct GoalDetailView: View { + @Environment(\.dismiss) private var dismiss @State private var store: StoreOf @State private var editorDestination: EditorDestination? @State private var detailDestination: DetailDestination? @@ -24,10 +25,12 @@ public struct GoalDetailView: View { public var body: some View { ScrollView { - LazyVStack(spacing: 20, pinnedViews: [.sectionHeaders]) { + LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) { Section { + let draft = store.items.first(where: \.hasDraft) timelineCard - if let draft = store.items.first(where: \.isDraft) { + .padding(.bottom, store.allowsRecordMutation && draft != nil ? 8 : 0) + if store.allowsRecordMutation, let draft { continueButton(draft.record) } } header: { @@ -36,7 +39,9 @@ public struct GoalDetailView: View { .padding(.horizontal) } } - .background(Color.appBackground) + .safeAreaInset(edge: .top, spacing: 0) { topBar } + .background(Color.appBackground.ignoresSafeArea()) + .toolbarVisibility(.hidden, for: .navigationBar) .onAppear { store.send(.view(.fetch)) } .prominentAlert(store, state: \.alert, action: \.alert) .sheet(item: $editorDestination) { destination in @@ -51,10 +56,42 @@ public struct GoalDetailView: View { RecordDetailView( goalTitle: store.goalTitle, record: destination.record, + allowsMutation: store.allowsRecordMutation, onUpdate: refresh ) } - .toolbarBackground(Color.appBackground) + .overlay { + if store.isTransitioning { + LoadingView() + } + } + } + + private var topBar: some View { + HStack { + RecordBackButton(action: dismiss.callAsFunction) + .disabled(store.isTransitioning) + Spacer() + if let status = store.goalStatus { + Image(systemName: "ellipsis") + .font(.title3.weight(.semibold)) + .frame(width: 28, height: 28) + .prominentMenu( + items: statusMenuItems(status), + isEnabled: !store.isLoading && !store.isTransitioning + ) { status in + store.send(.view(.selectStatus(status))) + } + .adaptiveButtonStyle( + shape: .circle, + color: .surface, + glassEffect: .enabled + ) + } + } + .padding(.horizontal) + .padding(.bottom, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) } private var titleBar: some View { @@ -73,7 +110,12 @@ public struct GoalDetailView: View { .contentMargins(.horizontal, 16, for: .scrollContent) .padding(.horizontal, -16) } + + if let status = store.goalStatus { + GoalStatusBadge(status: status) + } } + .padding(.bottom, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.appBackground) } @@ -113,6 +155,7 @@ public struct GoalDetailView: View { ForEach(Array(store.items.enumerated()), id: \.element.id) { index, item in TimelineRow( item: item, + isFirst: index == 0, isLast: index == store.items.count - 1, onSelect: { select(item) } ) @@ -120,7 +163,7 @@ public struct GoalDetailView: View { } } - if store.hasLoaded { + if store.hasLoaded, store.allowsRecordMutation { Button { editorDestination = EditorDestination(record: nil) } label: { @@ -156,13 +199,41 @@ public struct GoalDetailView: View { } private func select(_ item: RecordTimelineItem) { - if item.isDraft { + if item.hasDraft, store.allowsRecordMutation { editorDestination = EditorDestination(record: item.record) } else { detailDestination = DetailDestination(record: item.record) } } + private func statusMenuItems( + _ status: DevelopmentGoal.Status + ) -> [ProminentMenuItem] { + switch status { + case .inProgress: + [ + ProminentMenuItem( + action: .completed, + title: RecordPresentation.text("development_goal_complete"), + systemImage: "checkmark.circle" + ), + ProminentMenuItem( + action: .archived, + title: RecordPresentation.text("development_goal_archive"), + systemImage: "archivebox" + ) + ] + case .completed, .archived: + [ + ProminentMenuItem( + action: .inProgress, + title: RecordPresentation.text("development_goal_resume"), + systemImage: "arrow.counterclockwise" + ) + ] + } + } + private func finishEditing() { editorDestination = nil refresh() @@ -175,6 +246,7 @@ public struct GoalDetailView: View { private struct TimelineRow: View { let item: RecordTimelineItem + let isFirst: Bool let isLast: Bool let onSelect: () -> Void @@ -190,7 +262,7 @@ private struct TimelineRow: View { .foregroundStyle(Color.primary) .lineLimit(2) status - if item.isDraft { + if item.hasDraft { RelativeTimeText( date: item.date, bodyFont: .caption, @@ -209,36 +281,29 @@ private struct TimelineRow: View { .padding(.top, 4) } .contentShape(.rect) + .frame(minHeight: RecordTimelineLayout.rowHeight, alignment: .top) + .background(alignment: .topLeading) { + RecordTimelineConnector(isFirst: isFirst, isLast: isLast) + } } .buttonStyle(.plain) } private var timelineIndicator: some View { - VStack(spacing: 0) { - Circle() - .fill(item.isDraft ? Color.surface : .accent) - .frame(width: 13, height: 13) - .overlay { - Circle() - .strokeBorder(item.isDraft ? Color.warning : .accent, lineWidth: 2) - } - if !isLast { - Rectangle() - .fill(Color.accent.opacity(0.45)) - .frame(width: 2, height: 72) - } - } - .padding(.top, 3) + Circle() + .fill(item.hasDraft ? Color.warning : .accent) + .frame(width: RecordTimelineLayout.markerSize, height: RecordTimelineLayout.markerSize) + .padding(.top, RecordTimelineLayout.markerTopPadding) } private var status: some View { Text(statusText) .font(.caption) - .foregroundStyle(item.isDraft ? Color.warning : .accent) + .foregroundStyle(item.hasDraft ? Color.warning : .accent) .padding(.horizontal, 9) .padding(.vertical, 4) .background( - item.isDraft ? Color.warning.opacity(0.12) : Color.primaryContainer, + item.hasDraft ? Color.warning.opacity(0.12) : Color.primaryContainer, in: .rect(cornerRadius: 8) ) } @@ -254,6 +319,63 @@ private struct TimelineRow: View { } } +private struct GoalStatusBadge: View { + let status: DevelopmentGoal.Status + + var body: some View { + Label(title, systemImage: systemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(foreground) + .padding(.horizontal, 11) + .padding(.vertical, 6) + .background(background, in: .capsule) + } + + private var title: String { + switch status { + case .inProgress: + RecordPresentation.text("development_goal_status_in_progress") + case .completed: + RecordPresentation.text("development_goal_status_completed") + case .archived: + RecordPresentation.text("development_goal_status_archived") + } + } + + private var systemImage: String { + switch status { + case .inProgress: + "clock" + case .completed: + "checkmark.circle.fill" + case .archived: + "archivebox.fill" + } + } + + private var foreground: Color { + switch status { + case .inProgress: + .accent + case .completed: + .white + case .archived: + .textSecondary + } + } + + private var background: Color { + switch status { + case .inProgress: + .primaryContainer + case .completed: + .accent + case .archived: + .surfaceSecondary + } + } +} + private struct EditorDestination: Identifiable { let id = UUID() let record: DevelopmentRecord? diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift b/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift index abf3fc4c..9f76507b 100644 --- a/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift @@ -26,7 +26,11 @@ struct RecordVersionDetailView: View { .padding(.bottom, 20) } .safeAreaInset(edge: .top, spacing: 0) { topBar } - .safeAreaInset(edge: .bottom, spacing: 0) { restoreBar } + .safeAreaInset(edge: .bottom, spacing: 0) { + if store.allowsMutation { + restoreBar + } + } .background(Color.appBackground.ignoresSafeArea()) .toolbarVisibility(.hidden, for: .navigationBar) .prominentAlert( @@ -94,7 +98,9 @@ struct RecordVersionDetailView: View { private var currentVersionCard: some View { Label { - if store.record.draft != nil { + if !store.allowsMutation { + Text(RecordPresentation.text("development_record_read_only_message")) + } else if store.record.draft != nil { Text(RecordPresentation.text("development_record_restore_draft_message")) } else { Text(String.localizedStringWithFormat( @@ -104,9 +110,9 @@ struct RecordVersionDetailView: View { } } icon: { Image( - systemName: store.record.draft == nil ? "clock.arrow.circlepath" : "pencil" + systemName: statusSystemImage ) - .foregroundStyle(store.record.draft == nil ? Color.accent : Color.warning) + .foregroundStyle(statusColor) } .font(.subheadline) .foregroundStyle(Color.textSecondary) @@ -115,6 +121,16 @@ struct RecordVersionDetailView: View { .background(Color.surfaceSecondary, in: .rect(cornerRadius: 18)) } + private var statusSystemImage: String { + if !store.allowsMutation { return "lock" } + return store.record.draft == nil ? "clock.arrow.circlepath" : "pencil" + } + + private var statusColor: Color { + if !store.allowsMutation { return .textSecondary } + return store.record.draft == nil ? .accent : .warning + } + private var restoreBar: some View { Button { store.send(.view(.restore(version))) diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift index fd89cc2e..225e5252 100644 --- a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift +++ b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift @@ -124,7 +124,11 @@ struct RecordVersionHistoryView: View { private var footer: some View { Label( - RecordPresentation.text("development_record_history_footer"), + RecordPresentation.text( + store.allowsMutation + ? "development_record_history_footer" + : "development_record_history_read_only_footer" + ), systemImage: "lock" ) .font(.caption) @@ -180,13 +184,17 @@ private struct VersionHistoryRow: View { .foregroundStyle(Color.border) } .contentShape(.rect) + .frame(minHeight: RecordTimelineLayout.rowHeight, alignment: .top) .background(alignment: .topLeading) { if !isLast { Rectangle() .fill(Color.accent.opacity(0.45)) - .frame(width: 2) + .frame(width: RecordTimelineLayout.lineWidth) .frame(maxHeight: .infinity) - .offset(x: 5.5, y: 16) + .offset( + x: RecordTimelineLayout.lineXOffset, + y: RecordTimelineLayout.lineYOffset + ) } } } @@ -194,12 +202,11 @@ private struct VersionHistoryRow: View { private var timelineIndicator: some View { Circle() .fill(isCurrent ? Color.accent : Color.textTertiary) - .frame(width: 13, height: 13) - .overlay { - Circle() - .strokeBorder(isCurrent ? Color.accent : Color.border, lineWidth: 2) - } - .padding(.top, 3) + .frame( + width: RecordTimelineLayout.markerSize, + height: RecordTimelineLayout.markerSize + ) + .padding(.top, RecordTimelineLayout.markerTopPadding) } @ViewBuilder diff --git a/Application/Presentation/Development/Sources/Record/RecordPresentation.swift b/Application/Presentation/Development/Sources/Record/RecordPresentation.swift index fcdd7bbe..d274670c 100644 --- a/Application/Presentation/Development/Sources/Record/RecordPresentation.swift +++ b/Application/Presentation/Development/Sources/Record/RecordPresentation.swift @@ -5,7 +5,7 @@ // Created by opfic on 9/13/26. // -import Foundation +import SwiftUI import PresentationShared enum RecordPresentation { @@ -17,3 +17,39 @@ enum RecordPresentation { "#\(number)" } } + +enum RecordTimelineLayout { + static let rowHeight: CGFloat = 88 + static let markerSize: CGFloat = 13 + static let lineWidth: CGFloat = 2 + static let markerTopPadding: CGFloat = 3 + static let lineXOffset = (markerSize - lineWidth) / 2 + static let lineYOffset = markerTopPadding + markerSize / 2 +} + +struct RecordTimelineConnector: View { + let isFirst: Bool + let isLast: Bool + + var body: some View { + ZStack(alignment: .topLeading) { + if !isFirst { + line + .frame(height: RecordTimelineLayout.lineYOffset) + } + if !isLast { + line + .frame(maxHeight: .infinity) + .padding(.top, RecordTimelineLayout.lineYOffset) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var line: some View { + Rectangle() + .fill(Color.accent.opacity(0.45)) + .frame(width: RecordTimelineLayout.lineWidth) + .offset(x: RecordTimelineLayout.lineXOffset) + } +} diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift index c169d679..936eaf50 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift @@ -6,6 +6,7 @@ // import Testing +import Domain import Foundation import PresentationShared @testable import Development @@ -42,8 +43,8 @@ struct GoalDetailFeatureTests { await store.send(.view(.fetch)) { $0.isLoading = true } - await store.receive(.store(.loaded(goalTitle: goal.title, items: items))) { - $0.goalTitle = goal.title + await store.receive(.store(.loaded(goal: goal, items: items))) { + $0.goal = goal $0.items = items $0.isLoading = false $0.hasLoaded = true @@ -75,7 +76,7 @@ struct GoalDetailFeatureTests { currentVersion: currentVersion ) var state = GoalDetailFeature.State(goalId: goal.id) - state.goalTitle = goal.title + state.goal = goal state.items = [previousItem] state.hasLoaded = true let store = TestStore(initialState: state) { @@ -93,7 +94,7 @@ struct GoalDetailFeatureTests { await store.send(.view(.refresh)) { $0.isLoading = true } - await store.receive(.store(.loaded(goalTitle: goal.title, items: [currentItem]))) { + await store.receive(.store(.loaded(goal: goal, items: [currentItem]))) { $0.items = [currentItem] $0.isLoading = false } @@ -133,4 +134,219 @@ struct GoalDetailFeatureTests { $0.hasLoadFailure = true } } + + @Test("상태 전환 성공은 후속 재조회 없이 요청 상태를 반영한다") + func 상태_전환_성공은_후속_재조회_없이_요청_상태를_반영한다() async throws { + let goal = try makeDevelopmentGoal() + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub( + result: .failure(RecordTestError.failed) + ) + } + + await store.send(.view(.selectStatus(.archived))) { + $0.alert = GoalDetailFeature.transitionConfirmationAlert(.archived) + } + await store.send(.alert(.presented(.confirmTransition(.archived)))) { + $0.alert = nil + $0.isTransitioning = true + } + await store.receive(.store(.transitioned(.archived))) { + $0.updatedGoalStatus = .archived + $0.isTransitioning = false + } + + #expect(await spy.requests() == [ + .init(goalId: goal.id, status: .archived) + ]) + } + + @Test("반영한 상태는 다음 상태 전환의 기준으로 사용한다") + func 반영한_상태는_다음_상태_전환의_기준으로_사용한다() async throws { + let goal = try makeDevelopmentGoal() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.updatedGoalStatus = .archived + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } + + await store.send(.view(.selectStatus(.inProgress))) { + $0.alert = GoalDetailFeature.transitionConfirmationAlert(.inProgress) + } + } + + @Test("완료되거나 보관된 목표는 진행 중으로 되돌릴 수 있다") + func 완료되거나_보관된_목표는_진행_중으로_되돌릴_수_있다() async throws { + for status in [DevelopmentGoal.Status.completed, .archived] { + let goal = try makeDevelopmentGoal(status: status) + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + } + + await store.send(.view(.selectStatus(.inProgress))) { + $0.alert = GoalDetailFeature.transitionConfirmationAlert(.inProgress) + } + await store.send(.alert(.presented(.confirmTransition(.inProgress)))) { + $0.alert = nil + $0.isTransitioning = true + } + await store.receive(.store(.transitioned(.inProgress))) { + $0.updatedGoalStatus = .inProgress + $0.isTransitioning = false + } + + #expect(await spy.requests() == [ + .init(goalId: goal.id, status: .inProgress) + ]) + } + } + + @Test("개발 기록이 없으면 목표 완료 전에 기록 작성을 안내한다") + func 개발_기록이_없으면_목표_완료_전에_기록_작성을_안내한다() async throws { + let goal = try makeDevelopmentGoal() + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + } + + await store.send(.view(.selectStatus(.completed))) { + $0.alert = GoalDetailFeature.completionBlockingAlert(items: []) + } + + #expect(await spy.requests().isEmpty) + } + + @Test("마지막 기록이 초안이면 목표 완료 전에 기록 확정을 안내한다") + func 마지막_기록이_초안이면_목표_완료_전에_기록_확정을_안내한다() async throws { + let goal = try makeDevelopmentGoal() + let draft = try makeDevelopmentRecord() + let item = RecordTimelineItem(record: draft, currentVersion: nil) + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.items = [item] + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + } + + await store.send(.view(.selectStatus(.completed))) { + $0.alert = GoalDetailFeature.completionBlockingAlert(items: [item]) + } + + #expect(await spy.requests().isEmpty) + } + + @Test("정정 초안이 남아 있으면 목표 완료 전에 초안 확정을 안내한다") + func 정정_초안이_남아_있으면_목표_완료_전에_초안_확정을_안내한다() async throws { + let goal = try makeDevelopmentGoal() + let version = try makeDevelopmentRecordVersion() + let record = try makeConfirmedDevelopmentRecord( + draft: makeDevelopmentRecordDraft(baseVersionId: version.id) + ) + let item = RecordTimelineItem(record: record, currentVersion: version) + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.items = [item] + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + } + + await store.send(.view(.selectStatus(.completed))) { + $0.alert = GoalDetailFeature.completionBlockingAlert(items: [item]) + } + + #expect(await spy.requests().isEmpty) + } + + @Test("모든 기록이 확정되면 확인 후 목표를 완료한다") + func 모든_기록이_확정되면_확인_후_목표를_완료한다() async throws { + let goal = try makeDevelopmentGoal() + let record = try makeConfirmedDevelopmentRecord() + let version = try makeDevelopmentRecordVersion() + let item = RecordTimelineItem(record: record, currentVersion: version) + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.items = [item] + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + } + + await store.send(.view(.selectStatus(.completed))) { + $0.alert = GoalDetailFeature.transitionConfirmationAlert(.completed) + } + await store.send(.alert(.presented(.confirmTransition(.completed)))) { + $0.alert = nil + $0.isTransitioning = true + } + await store.receive(.store(.transitioned(.completed))) { + $0.updatedGoalStatus = .completed + $0.isTransitioning = false + } + + #expect(await spy.requests() == [ + .init(goalId: goal.id, status: .completed) + ]) + } + + @Test("목표 상태 전환 실패는 현재 상태를 유지하고 오류를 표시한다") + func 목표_상태_전환_실패는_현재_상태를_유지하고_오류를_표시한다() async throws { + let goal = try makeDevelopmentGoal() + let spy = UpdateDevelopmentGoalStatusUseCaseSpy(result: .failure(RecordTestError.failed)) + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + } + + await store.send(.view(.selectStatus(.archived))) { + $0.alert = GoalDetailFeature.transitionConfirmationAlert(.archived) + } + await store.send(.alert(.presented(.confirmTransition(.archived)))) { + $0.alert = nil + $0.isTransitioning = true + } + await store.receive(.store(.transitionFailed)) { + $0.isTransitioning = false + $0.alert = GoalDetailFeature.transitionErrorAlert + } + + #expect(store.state.goal == goal) + #expect(await spy.requests() == [ + .init(goalId: goal.id, status: .archived) + ]) + } } diff --git a/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift index 5ead5cfd..3a0ce762 100644 --- a/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift @@ -299,4 +299,36 @@ struct RecordDetailFeatureTests { #expect(await spy.requests().isEmpty) } + + @Test("읽기 전용 목표는 이전 버전 되돌리기를 시작하지 않는다") + func 읽기_전용_목표는_이전_버전_되돌리기를_시작하지_않는다() async throws { + let initialVersion = try makeDevelopmentRecordVersion(id: "version-1") + let currentVersion = try makeDevelopmentRecordVersion( + id: "version-2", + number: 2, + kind: .correction, + sourceVersionId: initialVersion.id + ) + let record = try makeConfirmedDevelopmentRecord( + versionId: currentVersion.id, + versionNumber: currentVersion.number + ) + let spy = RestoreDevelopmentRecordUseCaseSpy(result: .failure(RecordTestError.failed)) + var state = RecordDetailFeature.State( + goalTitle: "개발 목표", + record: record, + allowsMutation: false + ) + state.versions = [initialVersion, currentVersion] + state.contentState = .loaded + let store = TestStore(initialState: state) { + RecordDetailFeature() + } withDependencies: { + $0.developmentRestoreRecordUseCase = spy + } + + await store.send(.view(.restore(initialVersion))) + + #expect(await spy.requests().isEmpty) + } } diff --git a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift index df532552..754f2b4e 100644 --- a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift @@ -35,6 +35,29 @@ struct FetchDevelopmentGoalUseCaseStub: FetchDevelopmentGoalUseCase { } } +actor UpdateDevelopmentGoalStatusUseCaseSpy: UpdateDevelopmentGoalStatusUseCase { + struct Request: Equatable { + let goalId: String + let status: DevelopmentGoal.Status + } + + private let result: Result + private var recordedRequests = [Request]() + + init(result: Result = .success(())) { + self.result = result + } + + func execute(_ goalId: String, to status: DevelopmentGoal.Status) async throws { + recordedRequests.append(.init(goalId: goalId, status: status)) + try result.get() + } + + func requests() -> [Request] { + recordedRequests + } +} + struct FetchDevelopmentRecordsUseCaseStub: FetchDevelopmentRecordsUseCase { let result: Result<[DevelopmentRecord], Error> @@ -241,16 +264,19 @@ actor RestoreDevelopmentRecordUseCaseSpy: RestoreDevelopmentRecordUseCase { } } -func makeDevelopmentGoal(title: String = "개발 목표") throws -> DevelopmentGoal { +func makeDevelopmentGoal( + title: String = "개발 목표", + status: DevelopmentGoal.Status = .inProgress +) throws -> DevelopmentGoal { let date = Date(timeIntervalSince1970: 1_700_000_000) return try DevelopmentGoal( id: "goal", title: title, description: "설명", - status: .inProgress, + status: status, createdAt: date, updatedAt: date, - completedAt: nil + completedAt: status == .completed ? date : nil ) } diff --git a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift index 520072ad..417963c2 100644 --- a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift @@ -17,17 +17,23 @@ struct SearchView: View { var body: some View { NavigationStack(path: $router.path) { ScrollView { - LazyVStack(alignment: .leading, spacing: 24, pinnedViews: [.sectionHeaders]) { + LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { Section { if !store.searchQuery.isEmpty { SearchResults( store: store, onSelectTodo: { router.push(.todo($0)) } ) + .padding(.bottom, 8) } RecentSearchQuries(store: store) + .padding(.bottom, 8) instruction - } header: { tipCard } + } header: { + tipCard + .padding(.bottom, 8) + .background(Color.appBackground) + } } .padding(.horizontal) } diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 64f54296..0a7f736c 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -424,6 +424,146 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표" } } } }, + "development_goal_archive" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Archive" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "보관하기" } } + } + }, + "development_goal_archive_alert_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Archived goals are read-only until you move them back to in progress." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "보관하면 다시 진행 중으로 되돌릴 때까지 기록을 수정할 수 없어요." } } + } + }, + "development_goal_archive_alert_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Archive this goal?" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표를 보관할까요?" } } + } + }, + "development_goal_complete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Complete" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료하기" } } + } + }, + "development_goal_complete_alert_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Completed goals are read-only until you move them back to in progress." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료하면 다시 진행 중으로 되돌릴 때까지 기록을 수정할 수 없어요." } } + } + }, + "development_goal_complete_alert_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Complete this goal?" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표를 완료할까요?" } } + } + }, + "development_goal_completion_draft_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm every remaining draft, then try completing the goal again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "남아 있는 초안을 모두 확정한 뒤 목표 완료를 다시 시도해주세요." } } + } + }, + "development_goal_completion_draft_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm the remaining drafts" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "남은 초안을 확정해주세요" } } + } + }, + "development_goal_completion_record_required_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Add and confirm at least one development record before completing the goal." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록을 하나 이상 작성하고 확정한 뒤 목표를 완료할 수 있어요." } } + } + }, + "development_goal_completion_record_required_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "A confirmed record is required" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정된 기록이 필요해요" } } + } + }, + "development_goal_completion_version_required_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm the latest development record before completing the goal." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "마지막 개발 기록을 먼저 확정한 뒤 목표를 완료할 수 있어요." } } + } + }, + "development_goal_completion_version_required_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm the latest record" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "마지막 기록을 확정해주세요" } } + } + }, + "development_goal_resume" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Move to In Progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중으로 되돌리기" } } + } + }, + "development_goal_resume_alert_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "You can add and edit development records again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중으로 되돌리면 개발 기록을 다시 작성하고 수정할 수 있어요." } } + } + }, + "development_goal_resume_alert_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Resume this goal?" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표를 다시 진행할까요?" } } + } + }, + "development_goal_status_archived" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Archived" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "보관" } } + } + }, + "development_goal_status_completed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Completed" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료" } } + } + }, + "development_goal_status_in_progress" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "In Progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중" } } + } + }, + "development_goal_status_menu" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Change Goal Status" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 상태 변경" } } + } + }, + "development_goal_transition_error_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The goal status could not be changed. Please try again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 상태를 변경하지 못했어요. 다시 시도해주세요." } } + } + }, "development_record_add" : { "extractionState" : "manual", "localizations" : { @@ -585,6 +725,13 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "버전 이력" } } } }, + "development_record_history_read_only_footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Move the goal back to in progress to restore an earlier version." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 버전으로 되돌리려면 목표를 진행 중으로 되돌려주세요." } } + } + }, "development_record_markdown_hint" : { "extractionState" : "manual", "localizations" : { @@ -620,6 +767,13 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 버전" } } } }, + "development_record_read_only_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Move the goal back to in progress to change this record." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 기록을 변경하려면 목표를 진행 중으로 되돌려주세요." } } + } + }, "development_record_result_title" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+Menu.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+Menu.swift new file mode 100644 index 00000000..9d3d818e --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+Menu.swift @@ -0,0 +1,131 @@ +// +// View+Menu.swift +// PresentationShared +// +// Created by opfic on 9/14/26. +// + +import SwiftUI + +public struct ProminentMenuItem: Identifiable { + public let action: Action + public let title: String + public let systemImage: String? + public let role: ButtonRole? + + public var id: Action { action } + + public init( + action: Action, + title: String, + systemImage: String? = nil, + role: ButtonRole? = nil + ) { + self.action = action + self.title = title + self.systemImage = systemImage + self.role = role + } +} + +public extension View { + func prominentMenu( + items: [ProminentMenuItem], + isEnabled: Bool = true, + onSelect: @escaping (Action) -> Void + ) -> some View { + ProminentMenu( + label: self, + items: items, + isEnabled: isEnabled, + onSelect: onSelect + ) + } +} + +private struct ProminentMenu: View { + @State private var isPresented = false + + let label: Label + let items: [ProminentMenuItem] + let isEnabled: Bool + let onSelect: (Action) -> Void + + @ViewBuilder + var body: some View { + if #available(iOS 26.0, *) { + Menu { + ForEach(items) { item in + Button(role: item.role) { + onSelect(item.action) + } label: { + menuItemLabel(item) + } + } + } label: { + label + } + .disabled(!isEnabled) + } else { + Button { + isPresented.toggle() + } label: { + label + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .popover( + isPresented: $isPresented, + attachmentAnchor: .point(.bottomTrailing), + arrowEdge: .top + ) { + customMenu + .presentationCompactAdaptation(.popover) + .presentationBackground(.clear) + } + } + } + + private var customMenu: some View { + VStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + if 0 < index { + Divider() + } + + Button(role: item.role) { + isPresented = false + onSelect(item.action) + } label: { + menuItemLabel(item) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(.plain) + .padding(.horizontal, 18) + .frame(minHeight: 52) + } + } + .frame(minWidth: 220) + .background(Color.surface, in: .rect(cornerRadius: 20)) + .compositingGroup() + .clipShape(.rect(cornerRadius: 20)) + .shadow(color: Color.black.opacity(0.18), radius: 20, y: 10) + .padding(8) + } + + private func menuItemLabel(_ item: ProminentMenuItem) -> some View { + HStack(spacing: 12) { + if let systemImage = item.systemImage { + Image(systemName: systemImage) + .frame(width: 22) + } + + Text(item.title) + .font(.body) + + Spacer(minLength: 0) + } + .foregroundStyle(item.role == .destructive ? Color.red : Color.primary) + } +} diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 2f919b59..89b54d52 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -33,18 +33,20 @@ public struct TodayView: View { public var body: some View { NavigationStack(path: $path) { ScrollView { - LazyVStack(alignment: .leading, spacing: 8, pinnedViews: [.sectionHeaders]) { + let sections = store.sections + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { Section { - if store.sections.isEmpty, !store.isLoading { + if sections.isEmpty, !store.isLoading { emptyContent } else { - ForEach(store.sections) { section in + ForEach(sections) { section in TodoSection( section: section, isNavigationEnabled: !store.isTodoInspectorPresented, onSelect: { path.append(.todo(TodoIdItem(id: $0.id))) }, onInspect: { store.send(.showTodoInspector($0)) } ) + .padding(.bottom, section.id == sections.last?.id ? 0 : 8) } .padding(.bottom, 12) } @@ -54,6 +56,7 @@ public struct TodayView: View { achievementCard filterBar } + .padding(.bottom, 8) .background(Color.appBackground) } } @@ -67,6 +70,7 @@ public struct TodayView: View { CategoryFilterSheet(store: store) } } + .toolbarBackground(Color.appBackground) .inspector(isPresented: $store.isTodoInspectorPresented) { todoInspector }