Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ private extension AppGraph {
&dependencies,
fetchGoalUseCase: developmentGraphSet
.developmentGoalUseCaseGraph
.fetchDevelopmentGoalUseCase
.fetchDevelopmentGoalUseCase,
updateGoalStatusUseCase: developmentGraphSet
.developmentGoalUseCaseGraph
.updateDevelopmentGoalStatusUseCase
)
DevelopmentDependencyPreparation.prepareQuery(
&dependencies,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct RecordDetailFeature {
struct State: Equatable {
@Presents var alert: AlertState<Action.Alert>?
let goalTitle: String
let allowsMutation: Bool
var record: DevelopmentRecord
var versions = [DevelopmentRecord.Version]()
var contentState: ContentState
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -49,7 +51,11 @@ public struct RecordDetailView: View {
markdownContent: version.markdownContent,
version: version
)
actionSection
if store.allowsMutation {
actionSection
} else {
historyButton
}
}
case .failed:
failureContent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Never>?
@Presents var alert: AlertState<Action.Alert>?
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<Never>)
case alert(PresentationAction<Alert>)
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<Self> {
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):
Expand All @@ -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
Expand Down Expand Up @@ -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<Action> {
.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<Never> {
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<Action.Alert>? {
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<Action.Alert> {
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<Action.Alert> {
AlertState {
TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle))
} actions: {
Expand All @@ -155,4 +293,39 @@ extension GoalDetailFeature {
))
}
}

static var transitionErrorAlert: AlertState<Action.Alert> {
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<Action.Alert> {
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))
}
}
}
Loading