diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 134d97a1..29bc0262 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -206,6 +206,12 @@ private extension AppGraph { } func prepareProfileDependencies(_ dependencies: inout DependencyValues) { + ProfilePresentationDependencyPreparation.prepareDevelopmentGoals( + &dependencies, + fetchGoalsUseCase: developmentGraphSet + .developmentGoalUseCaseGraph + .fetchDevelopmentGoalsUseCase + ) ProfilePresentationDependencyPreparation.prepareUser( &dependencies, fetchUserDataUseCase: userProfileGraphSet.userDataUseCaseGraph.fetchUserDataUseCase, diff --git a/Application/Presentation/HomeTab/Sources/Home/DevelopmentSummaryCard.swift b/Application/Presentation/HomeTab/Sources/Home/DevelopmentSummaryCard.swift index c4498ee2..4e7e0e21 100644 --- a/Application/Presentation/HomeTab/Sources/Home/DevelopmentSummaryCard.swift +++ b/Application/Presentation/HomeTab/Sources/Home/DevelopmentSummaryCard.swift @@ -39,13 +39,22 @@ struct DevelopmentSummaryCard: View { .accessibilityElement(children: .combine) } + @ViewBuilder private var summaryTitle: some View { + let format = if items.count == 1 { + String( + localized: "home_development_summary_title_singular_format", + bundle: PresentationResources.bundle + ) + } else { + String( + localized: "home_development_summary_title_format", + bundle: PresentationResources.bundle + ) + } Text( String.localizedStringWithFormat( - String( - localized: "home_development_summary_title_format", - bundle: PresentationResources.bundle - ), + format, items.count ) ) diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 505ad0d7..98fbec8f 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -469,6 +469,13 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 목표가 %lld개 있어요" } } } }, + "home_development_summary_title_singular_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld goal is in progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 목표가 %lld개 있어요" } } + } + }, "home_development_goal_count_format" : { "extractionState" : "manual", "localizations" : { @@ -1716,19 +1723,26 @@ } } }, + "profile_goal_retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Try Again" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "다시 시도" } } + } + }, "profile_quarterly_activity" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Quarterly Activity" + "value" : "Development Activity" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "분기별 활동" + "value" : "개발 활동" } } } diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ActivityCard.swift b/Application/Presentation/ProfileTab/Sources/Profile/ActivityCard.swift new file mode 100644 index 00000000..7e402233 --- /dev/null +++ b/Application/Presentation/ProfileTab/Sources/Profile/ActivityCard.swift @@ -0,0 +1,244 @@ +// +// ActivityCard.swift +// ProfileTab +// +// Created by opfic on 9/24/26. +// + +import SwiftUI +import Core +import Domain +import PresentationShared + +struct ActivityCard: View { + @Bindable var store: StoreOf + let onSelectTodoID: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text(String(localized: "profile_quarterly_activity", bundle: PresentationResources.bundle)) + .font(.headline) + Spacer() + if !store.isViewingCurrentQuarter { + Button { + store.send(.moveToCurrentQuarter) + } label: { + Image(systemName: "arrow.uturn.backward") + .bold() + .foregroundStyle(Color.accent) + } + .buttonStyle(.plain) + } + Menu { + ForEach(ActivityKindItem.selectableItems) { activityKindItem in + if let activityKind = ActivityKind(rawValue: activityKindItem.rawValue) { + switch activityKind { + case .created: + Toggle(activityKindItem.title, isOn: $store.isCreatedActivitySelected) + .disabled(store.isCreatedActivityToggleDisabled) + case .completed: + Toggle(activityKindItem.title, isOn: $store.isCompletedActivitySelected) + .disabled(store.isCompletedActivityToggleDisabled) + case .deleted: + Toggle(activityKindItem.title, isOn: $store.isDeletedActivitySelected) + .disabled(store.isDeletedActivityToggleDisabled) + } + } + } + } label: { + Image(systemName: "line.3.horizontal.decrease") + .bold() + .foregroundStyle(Color.accent) + } + } + + HStack { + Button { + store.send(.moveQuarter(-1)) + } label: { + Image(systemName: "chevron.left") + } + .tint(Color.accent) + .disabled(!store.canMoveToPreviousQuarter) + Spacer() + Button { + store.send(.openQuarterPicker) + } label: { + HStack(spacing: 4) { + Text(store.quarterTitle) + .font(.subheadline) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + Spacer() + Button { + store.send(.moveQuarter(1)) + } label: { + Image(systemName: "chevron.right") + } + .tint(Color.accent) + .disabled(!store.canMoveToNextQuarter) + } + + if let quarter = store.activityQuarter { + HeatmapView( + quarter: quarter, + selectedActivityKinds: store.selectedActivityKinds, + selectedDay: store.selectedDay, + onSelectDay: { store.send(.selectDay($0)) } + ) + if let selectedDay = store.selectedDay { + selectedDayDetailSection(for: selectedDay) + } + } + } + .padding(16) + .background(Color.surface, in: RoundedRectangle(cornerRadius: 16)) + } + + @ViewBuilder + private func selectedDayDetailSection(for day: HeatmapDay) -> some View { + let activities = store.selectedDayActivities + + VStack(alignment: .leading, spacing: 12) { + Text(day.date.formatted(.dateTime.year().month(.wide).day())) + .font(.subheadline) + .bold() + + if activities.isEmpty { + Text(String(localized: "profile_activity_none", bundle: PresentationResources.bundle)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) + } else { + ForEach(activities) { activity in + Button { + selectActivity(activity) + } label: { + let item = TodoCategoryItem(from: activity.category) + let rowColor = activity.isDeleted ? Color.secondary : .primary + HStack(spacing: 8) { + Image(systemName: item.symbolName) + .foregroundStyle(item.color) + .frame(width: 20) + Text(activity.title) + .font(.caption) + .lineLimit(1) + .foregroundStyle(rowColor) + Text("#\(activity.number)") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(activity.activityKindItems) { item in + Text(item.title) + .font(.caption2) + .foregroundStyle(item.badgeColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(item.badgeColor.opacity(0.14)) + ) + } + Spacer() + if !activity.isDeleted { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .contentShape(.rect) + } + .buttonStyle(.plain) + .disabled(activity.isDeleted) + .padding(.vertical, 2) + } + } + } + .padding(.top, 4) + } + + private func selectActivity(_ activity: HeatmapActivityItem) { + guard !activity.isDeleted else { return } + onSelectTodoID(activity.todoId) + } +} + +struct QuarterPickerSheet: View { + @Bindable var store: StoreOf + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 20) { + HStack { + Text(String(localized: "profile_year", bundle: PresentationResources.bundle)) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Picker( + "", + selection: $store.selectedQuarterPickerYear + ) { + ForEach(store.availableQuarterYears, id: \.self) { year in + Text(verbatim: String(year)) + .tag(year) + } + } + .pickerStyle(.menu) + .labelsHidden() + } + + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), spacing: 12) { + ForEach(1...4, id: \.self) { quarter in + quarterSelectionButton(for: quarter) + } + } + + Spacer(minLength: 0) + } + .padding(20) + .navigationTitle(String(localized: "profile_select_quarter", bundle: PresentationResources.bundle)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarTrailingButton { + store.send(.setQuarterPickerPresented(false)) + } + } + } + .presentationDetents([.fraction(0.3)]) + .presentationDragIndicator(.visible) + } + + @ViewBuilder + private func quarterSelectionButton(for quarter: Int) -> some View { + let quarterStart = store.state.quarterStartForPicker(quarter: quarter) + let isEnabled = store.state.isQuarterSelectableForPicker(quarter) + let isSelected = store.state.isQuarterSelectedForPicker(quarter) + + Button { + guard let quarterStart else { return } + store.send(.selectQuarter(quarterStart)) + } label: { + Text( + String.localizedStringWithFormat( + String(localized: "profile_quarter_format", bundle: PresentationResources.bundle), + Int64(quarter) + ) + ) + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? Color.accent : Color.surfaceSecondary) + ) + .foregroundStyle(isSelected ? .white : isEnabled ? .primary : .secondary) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Profile/GoalSummaryCard.swift b/Application/Presentation/ProfileTab/Sources/Profile/GoalSummaryCard.swift new file mode 100644 index 00000000..7220ebdc --- /dev/null +++ b/Application/Presentation/ProfileTab/Sources/Profile/GoalSummaryCard.swift @@ -0,0 +1,119 @@ +// +// GoalSummaryCard.swift +// ProfileTab +// +// Created by opfic on 9/24/26. +// + +import SwiftUI +import Domain +import PresentationShared + +private struct GoalCounts { + let inProgress: Int + let completed: Int + let archived: Int + + init(goals: [DevelopmentGoal]) { + var inProgress = 0 + var completed = 0 + var archived = 0 + for goal in goals { + switch goal.status { + case .inProgress: + inProgress += 1 + case .completed: + completed += 1 + case .archived: + archived += 1 + } + } + self.inProgress = inProgress + self.completed = completed + self.archived = archived + } +} + +struct GoalSummaryCard: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + let goals: [DevelopmentGoal] + let isLoading: Bool + let hasLoaded: Bool + let hasLoadFailure: Bool + let onRetry: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("development_goal_title", bundle: PresentationResources.bundle) + .font(.title3.bold()) + + if hasLoaded { + statusGrid + } else if isLoading || !hasLoadFailure { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 80) + } + + if hasLoadFailure { + HStack(spacing: 12) { + Text("common_error_message", bundle: PresentationResources.bundle) + .font(.caption) + .foregroundStyle(Color.textSecondary) + Spacer(minLength: 0) + Button(action: onRetry) { + Text("profile_goal_retry", bundle: PresentationResources.bundle) + .font(.caption.weight(.semibold)) + } + } + } + } + .padding(16) + .background(Color.surface, in: RoundedRectangle(cornerRadius: 16)) + } + + private var statusGrid: some View { + let counts = GoalCounts(goals: goals) + let layout = dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(spacing: 8)) + : AnyLayout(HStackLayout(spacing: 8)) + return layout { + statusTile( + title: "development_goal_status_in_progress", + symbol: "clock", + color: .accent, + count: counts.inProgress + ) + statusTile( + title: "development_goal_status_completed", + symbol: "checkmark", + color: .success, + count: counts.completed + ) + statusTile( + title: "development_goal_status_archived", + symbol: "archivebox", + color: .textSecondary, + count: counts.archived + ) + } + } + + private func statusTile(title: LocalizedStringKey, symbol: String, color: Color, count: Int) -> some View { + VStack(spacing: 8) { + Image(systemName: symbol) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(color) + .frame(width: 36, height: 36) + .background(Color.surface, in: .circle) + Text(title, bundle: PresentationResources.bundle) + .font(.caption) + .foregroundStyle(Color.textSecondary) + Text(verbatim: String(count)) + .font(.title3.bold()) + .foregroundStyle(color) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(color.opacity(0.07), in: RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Profile/HeatmapView.swift b/Application/Presentation/ProfileTab/Sources/Profile/HeatmapView.swift index 42cd53b8..4a697127 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/HeatmapView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/HeatmapView.swift @@ -180,7 +180,7 @@ private struct MonthCompactHeatmapView: View { if count == 0 { return Color(.systemGray5) } - return Color.blue.opacity(opacity(for: count, max: maxCount)) + return Color.accent.opacity(opacity(for: count, max: maxCount)) } private func dayCount(for day: HeatmapDay) -> Int { diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileDependencyPreparation.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileDependencyPreparation.swift index c0b38830..99f01c00 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileDependencyPreparation.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileDependencyPreparation.swift @@ -9,6 +9,13 @@ import Domain import PresentationShared public enum ProfileDependencyPreparation { + public static func prepareDevelopmentGoals( + _ dependencies: inout DependencyValues, + fetchGoalsUseCase: FetchDevelopmentGoalsUseCase + ) { + dependencies.profileFetchDevelopmentGoalsUseCase = fetchGoalsUseCase + } + public static func prepareUser( _ dependencies: inout DependencyValues, fetchUserDataUseCase: FetchUserDataUseCase, diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+Dependencies.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+Dependencies.swift index 50be8a1c..3fa0f7f4 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+Dependencies.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+Dependencies.swift @@ -9,6 +9,11 @@ import PresentationShared import Domain extension DependencyValues { + var profileFetchDevelopmentGoalsUseCase: FetchDevelopmentGoalsUseCase { + get { self[FetchDevelopmentGoalsKey.self] } + set { self[FetchDevelopmentGoalsKey.self] = newValue } + } + var profileFetchUserDataUseCase: FetchUserDataUseCase { get { self[FetchUserDataKey.self] } set { self[FetchUserDataKey.self] = newValue } @@ -45,6 +50,12 @@ extension DependencyValues { } } +private enum FetchDevelopmentGoalsKey: DependencyKey { + static var liveValue: FetchDevelopmentGoalsUseCase { + preconditionFailure("FetchDevelopmentGoalsUseCase must be provided.") + } +} + private enum FetchUserDataKey: DependencyKey { static var liveValue: FetchUserDataUseCase { preconditionFailure("FetchUserDataUseCase must be provided.") diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+State.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+State.swift index 03804c2f..3b25e415 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+State.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+State.swift @@ -18,6 +18,10 @@ extension ProfileFeature.State { loading.visibleTargets.contains(ProfileFeature.LoadingTarget.recentTodos.target) } + var isDevelopmentGoalsLoading: Bool { + loading.visibleTargets.contains(ProfileFeature.LoadingTarget.developmentGoals.target) + } + var quarterTitle: String { guard let start = selectedQuarterStart else { return "" } let year = Calendar.current.component(.year, from: start) diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature.swift index 3b1a437b..d0bf38bc 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature.swift @@ -18,10 +18,13 @@ struct ProfileFeature { } enum LoadingTarget: Hashable { + case developmentGoals case recentTodos var target: LoadingFeature.Target { switch self { + case .developmentGoals: + return LoadingFeature.Target("profile.developmentGoals") case .recentTodos: return LoadingFeature.Target("profile.recentTodos") } @@ -38,6 +41,9 @@ struct ProfileFeature { var avatarURL: URL? var avatarImageData: AvatarImageData? var recentTodos = [RecentTodoItem]() + var developmentGoals = [DevelopmentGoal]() + var hasDevelopmentGoalsLoaded = false + var hasDevelopmentGoalsLoadFailure = false var earliestQuarterStart: Date? var selectedQuarterStart: Date? var showQuarterPicker = false @@ -57,6 +63,7 @@ struct ProfileFeature { case fetchData case refresh case refreshRecentTodos + case retryDevelopmentGoals case networkStatusChanged(Bool) case setAlert(Bool) case tapResetStatusMessageButton @@ -80,12 +87,15 @@ struct ProfileFeature { dayActivitiesByDate: [Date: [HeatmapActivityItem]] ) case updateRecentTodos([RecentTodoItem]) + case developmentGoalsLoaded([DevelopmentGoal]) + case developmentGoalsLoadFailed } } @Dependency(\.profileFetchUserDataUseCase) var fetchUserDataUseCase @Dependency(\.profileFetchImageDataUseCase) var fetchProfileImageDataUseCase @Dependency(\.profileFetchTodosUseCase) var fetchTodosUseCase + @Dependency(\.profileFetchDevelopmentGoalsUseCase) var fetchDevelopmentGoalsUseCase @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchPreferencesUseCase @Dependency(\.profileTodoMutationEventBus) var todoMutationEventBus @Dependency(\.profileUpsertStatusMessageUseCase) var upsertStatusMessageUseCase @@ -118,6 +128,7 @@ struct ProfileFeature { observeTodoMutationEffect() ) case .fetchData, .refresh: + state.hasDevelopmentGoalsLoadFailure = false if state.selectedQuarterStart == nil, let quarterStart = HeatmapBuilder.quarterStart(for: Date()) { state.selectedQuarterStart = quarterStart @@ -132,15 +143,20 @@ struct ProfileFeature { return .merge( fetchUserDataEffect(), fetchActivityQuarterEffect(selectedQuarterStart, showsIndicator: showsIndicator), - fetchRecentTodosEffect() + fetchRecentTodosEffect(), + fetchDevelopmentGoalsEffect() ) } return .merge( fetchUserDataEffect(), - fetchRecentTodosEffect() + fetchRecentTodosEffect(), + fetchDevelopmentGoalsEffect() ) case .refreshRecentTodos: return fetchRecentTodosEffect() + case .retryDevelopmentGoals: + state.hasDevelopmentGoalsLoadFailure = false + return fetchDevelopmentGoalsEffect() case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected case .setAlert(let isPresented): @@ -209,6 +225,12 @@ struct ProfileFeature { state.dayActivitiesByDate = dayActivitiesByDate case .store(.updateRecentTodos(let todos)): state.recentTodos = todos + case .store(.developmentGoalsLoaded(let goals)): + state.developmentGoals = goals + state.hasDevelopmentGoalsLoaded = true + state.hasDevelopmentGoalsLoadFailure = false + case .store(.developmentGoalsLoadFailed): + state.hasDevelopmentGoalsLoadFailure = true case .loading: break } @@ -239,6 +261,19 @@ private extension ProfileFeature { } } + func fetchDevelopmentGoalsEffect() -> Effect { + .run { [fetchDevelopmentGoalsUseCase] send in + await send(.loading(.begin(target: LoadingTarget.developmentGoals.target, mode: .immediate))) + do { + let goals = try await fetchDevelopmentGoalsUseCase.execute(.init()) + await send(.store(.developmentGoalsLoaded(goals))) + } catch { + await send(.store(.developmentGoalsLoadFailed)) + } + await send(.loading(.end(target: LoadingTarget.developmentGoals.target, mode: .immediate))) + } + } + func fetchAvatarImageDataEffect(_ url: URL) -> Effect { .run { [fetchProfileImageDataUseCase] send in do { diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift index fc0d1876..2f3d2fa9 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -5,7 +5,6 @@ // Created by opfic on 5/7/25. // -// swiftlint:disable file_length import SwiftUI import Core import Domain @@ -39,13 +38,27 @@ public struct ProfileView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { Section { - Card(store: store, isSelected: isSelected) + UserInfoCard(store: store, isSelected: isSelected) + ActivityCard(store: store) { todoId in + path.append(.activity(todoId)) + } + GoalSummaryCard( + goals: store.developmentGoals, + isLoading: store.isDevelopmentGoalsLoading, + hasLoaded: store.hasDevelopmentGoalsLoaded, + hasLoadFailure: store.hasDevelopmentGoalsLoadFailure, + onRetry: { store.send(.retryDevelopmentGoals) } + ) RecentActivityCard(store: store) { todoId in path.append(.recentTodo(todoId)) } - } header: { titleBar } + } header: { + titleBar + .toolbarBackground(Color.appBackground) + } } .padding(.horizontal, 16) + .padding(.bottom, 24) } .refreshable { await store.send(.refresh).finish() } .toolbarVisibility(.hidden, for: .navigationBar) @@ -64,7 +77,7 @@ public struct ProfileView: View { .prominentAlert(store, state: \.alert, action: \.alert) .sheet( isPresented: $store.showQuarterPicker.activePresentation(when: isSelected) - ) { quarterPickerSheet } + ) { QuarterPickerSheet(store: store) } .overlay { if store.isLoading { LoadingView() @@ -75,7 +88,7 @@ public struct ProfileView: View { private var titleBar: some View { VStack(alignment: .leading) { HStack { - Text("프로필") + Text("nav_profile", bundle: PresentationResources.bundle) .font(.largeTitle.bold()) Spacer() if #available(iOS 26.0, *) { @@ -95,10 +108,9 @@ public struct ProfileView: View { .adaptiveButtonStyle() } } - Text("꾸준히 쌓아온 개발 기록을 확인하세요") - .foregroundStyle(Color.textSecondary) - .font(.caption) } + .padding(.bottom, 8) + .background(Color.appBackground) } @ViewBuilder @@ -132,441 +144,6 @@ public struct ProfileView: View { }) } } - - private var activityHeatmapSection: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text(String(localized: "profile_quarterly_activity", bundle: PresentationResources.bundle)) - .font(.headline) - Spacer() - if !store.isViewingCurrentQuarter { - Button { - store.send(.moveToCurrentQuarter) - } label: { - Image(systemName: "arrow.uturn.backward") - .bold() - .foregroundStyle(.blue) - } - .buttonStyle(.plain) - } - Menu { - ForEach(ActivityKindItem.selectableItems) { activityKindItem in - if let activityKind = ActivityKind(rawValue: activityKindItem.rawValue) { - switch activityKind { - case .created: - Toggle(activityKindItem.title, isOn: $store.isCreatedActivitySelected) - .disabled(store.isCreatedActivityToggleDisabled) - case .completed: - Toggle(activityKindItem.title, isOn: $store.isCompletedActivitySelected) - .disabled(store.isCompletedActivityToggleDisabled) - case .deleted: - Toggle(activityKindItem.title, isOn: $store.isDeletedActivitySelected) - .disabled(store.isDeletedActivityToggleDisabled) - } - } - } - } label: { - Image(systemName: "line.3.horizontal.decrease") - .bold() - .foregroundStyle(.blue) - } - } - - HStack { - Button { - store.send(.moveQuarter(-1)) - } label: { - Image(systemName: "chevron.left") - } - .disabled(!store.canMoveToPreviousQuarter) - Spacer() - Button { - store.send(.openQuarterPicker) - } label: { - HStack(spacing: 4) { - Text(store.quarterTitle) - .font(.subheadline) - Image(systemName: "chevron.up.chevron.down") - .font(.caption2) - } - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - Spacer() - Button { - store.send(.moveQuarter(1)) - } label: { - Image(systemName: "chevron.right") - } - .disabled(!store.canMoveToNextQuarter) - } - - if let quarter = store.activityQuarter { - HeatmapView( - quarter: quarter, - selectedActivityKinds: store.selectedActivityKinds, - selectedDay: store.selectedDay, - onSelectDay: { store.send(.selectDay($0)) } - ) - if let selectedDay = store.selectedDay { - selectedDayDetailSection(for: selectedDay) - } - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 14) - .fill(Color(.secondarySystemGroupedBackground)) - ) - } - - private var quarterPickerSheet: some View { - NavigationStack { - VStack(alignment: .leading, spacing: 20) { - HStack { - Text(String(localized: "profile_year", bundle: PresentationResources.bundle)) - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - Picker( - "", - selection: $store.selectedQuarterPickerYear - ) { - ForEach(store.availableQuarterYears, id: \.self) { year in - Text(verbatim: String(year)) - .tag(year) - } - } - .pickerStyle(.menu) - .labelsHidden() - } - - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), spacing: 12) { - ForEach(1...4, id: \.self) { quarter in - quarterSelectionButton(for: quarter) - } - } - - Spacer(minLength: 0) - } - .padding(20) - .navigationTitle(String(localized: "profile_select_quarter", bundle: PresentationResources.bundle)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarTrailingButton { - store.send(.setQuarterPickerPresented(false)) - } - } - } - .presentationDetents([.fraction(0.3)]) - .presentationDragIndicator(.visible) - } - - @ViewBuilder - private func quarterSelectionButton(for quarter: Int) -> some View { - let quarterStart = store.state.quarterStartForPicker(quarter: quarter) - let isEnabled = store.state.isQuarterSelectableForPicker(quarter) - let isSelected = store.state.isQuarterSelectedForPicker(quarter) - - Button { - guard let quarterStart else { return } - store.send(.selectQuarter(quarterStart)) - } label: { - Text( - String.localizedStringWithFormat( - String(localized: "profile_quarter_format", bundle: PresentationResources.bundle), - Int64(quarter) - ) - ) - .font(.subheadline.weight(.semibold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(isSelected ? Color.blue : Color(.systemGray5)) - ) - .foregroundStyle(isSelected ? .white : isEnabled ? .primary : .secondary) - } - .buttonStyle(.plain) - .disabled(!isEnabled) - } - - @ViewBuilder - private func selectedDayDetailSection(for day: HeatmapDay) -> some View { - let activities = store.selectedDayActivities - - VStack(alignment: .leading, spacing: 12) { - Text(day.date.formatted(.dateTime.year().month(.wide).day())) - .font(.subheadline) - .bold() - - if activities.isEmpty { - Text(String(localized: "profile_activity_none", bundle: PresentationResources.bundle)) - .font(.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 8) - } else { - ForEach(activities) { activity in - Button { - selectActivity(activity) - } label: { - let item = TodoCategoryItem(from: activity.category) - let rowColor = activity.isDeleted ? Color.secondary : .primary - HStack(spacing: 8) { - Image(systemName: item.symbolName) - .foregroundStyle(item.color) - .frame(width: 20) - Text(activity.title) - .font(.caption) - .lineLimit(1) - .foregroundStyle(rowColor) - Text("#\(activity.number)") - .font(.caption) - .foregroundStyle(.secondary) - ForEach(activity.activityKindItems) { item in - Text(item.title) - .font(.caption2) - .foregroundStyle(item.badgeColor) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(item.badgeColor.opacity(0.14)) - ) - } - Spacer() - if !activity.isDeleted { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - } - } - .contentShape(.rect) - } - .buttonStyle(.plain) - .disabled(activity.isDeleted) - .padding(.vertical, 2) - } - } - } - .padding(.top, 4) - } - - private func selectActivity(_ activity: HeatmapActivityItem) { - guard !activity.isDeleted else { return } - path.append(.activity(activity.todoId)) - } -} - -private struct Card: View { - @Bindable var store: StoreOf - @FocusState private var focused: Bool - let isSelected: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Group { - if let data = store.avatarImageData?.data, - let uiImage = UIImage(data: data) { - Image(uiImage: uiImage) - .resizable() - .scaledToFill() - } else { - Image(systemName: "person.crop.circle.fill") - .resizable() - .scaledToFill() - .symbolRenderingMode(.palette) - .foregroundStyle(Color.onPrimaryContainer, Color.primaryContainer) - } - } - .frame(width: 60, height: 60) - .cornerRadius(30) - .transaction { $0.animation = nil } - - VStack(alignment: .leading) { - Text(store.name) - .font(.title2) - .bold() - Text(store.email) - .font(.caption2) - .foregroundStyle(Color.gray) - } - } - - HStack { - HStack { - Image(systemName: "face.smiling") - TextField( - text: $store.statusMessage - ) { - Text(String(localized: "profile_status_placeholder", bundle: PresentationResources.bundle)) - } - .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) - .focused($focused) - .disabled(!store.isNetworkConnected) - - if !store.statusMessage.isEmpty, - store.showDoneButton { - Button { - store.send(.tapResetStatusMessageButton) - } label: { - Image(systemName: "xmark.circle.fill") - } - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } - .foregroundStyle(Color.onPrimaryContainer) - .padding(8) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color.primaryContainer) - ) - if store.showDoneButton { - Button { - focused = false - store.send(.willUpdateStatusMessage) - } label: { - Text(String(localized: "profile_done", bundle: PresentationResources.bundle)) - } - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } - .opacity(store.isNetworkConnected ? 1 : 0.7) - } - .onChange(of: isSelected, initial: true) { _, isSelected in - if !isSelected { - focused = false - } - } - .onChange(of: focused) { _, focused in - store.send(.updateStatusTextFieldFocus(focused), animation: .default) - } - } -} - -// 개발 활동 카드 -private struct DevActivityCard: View { - @Bindable var store: StoreOf - - var body: some View { - - } -} - -// 개발 목표 카드 -private struct DevAchieveMentCard: View { - @Bindable var store: StoreOf - - var body: some View { - - } -} - -// 최근 활동 카드 -private struct RecentActivityCard: View { - @Bindable var store: StoreOf - @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) - let onSelectTodo: (String) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(String(localized: "profile_recent_title", bundle: PresentationResources.bundle)) - .font(.title2.bold()) - - Group { - if store.isRecentTodosLoading && store.recentTodos.isEmpty { - LoadingView() - .frame(maxWidth: .infinity, minHeight: 80) - } else if store.recentTodos.isEmpty { - Text(String(localized: "profile_recent_empty", bundle: PresentationResources.bundle)) - .font(.callout) - .foregroundStyle(Color.textSecondary) - .frame(maxWidth: .infinity, minHeight: 80) - } else { - VStack(spacing: 0) { - ForEach(Array(store.recentTodos.enumerated()), id: \.element.id) { index, todo in - Button { - onSelectTodo(todo.id) - } label: { - HStack(spacing: 12) { - RecentTodoRow(todo: todo) - Spacer(minLength: 0) - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(Color.textTertiary) - } - .contentShape(.rect) - } - .buttonStyle(.plain) - .todoDetailPreview(todoId: todo.id) - .padding(.vertical, 12) - - if index < store.recentTodos.count - 1 { - Divider() - .padding(.leading, labelWidth + 12) - } - } - } - } - } - .padding(.horizontal, 16) - .background( - RoundedRectangle(cornerRadius: 14) - .fill(Color(.secondarySystemGroupedBackground)) - ) - } - } -} - -private struct RecentTodoRow: View { - @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) - let todo: RecentTodoItem - - var body: some View { - let category = TodoCategoryItem(from: todo.category) - HStack(alignment: .top, spacing: 12) { - RoundedRectangle(cornerRadius: 8) - .fill(category.color) - .frame(width: labelWidth, height: labelWidth) - .overlay { - Image(systemName: category.symbolName) - .foregroundStyle(Color.white) - .font(.title3) - } - - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - if todo.isPinned { - Image(systemName: "star.fill") - .font(.caption.weight(.semibold)) - .foregroundStyle(.orange) - } - Text(todo.title) - .foregroundStyle(Color.primary) - .font(.headline) - .lineLimit(1) - Text("#\(todo.number)") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.gray) - .fixedSize(horizontal: true, vertical: false) - } - - HStack(spacing: 6) { - Text(category.localizedName) - .font(.caption.weight(.semibold)) - .foregroundStyle(category.color) - - RelativeTimeText(date: todo.updatedAt) - } - - if !todo.tags.isEmpty { - TagList(todo.tags, lineLimit: 1) - } - } - } - } } enum ProfileRoute: Hashable { diff --git a/Application/Presentation/ProfileTab/Sources/Profile/RecentActivityCard.swift b/Application/Presentation/ProfileTab/Sources/Profile/RecentActivityCard.swift new file mode 100644 index 00000000..f8a11522 --- /dev/null +++ b/Application/Presentation/ProfileTab/Sources/Profile/RecentActivityCard.swift @@ -0,0 +1,109 @@ +// +// RecentActivityCard.swift +// ProfileTab +// +// Created by opfic on 9/24/26. +// + +import SwiftUI +import PresentationShared + +// 최근 활동 카드 +struct RecentActivityCard: View { + @Bindable var store: StoreOf + let onSelectTodo: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("profile_recent_title", bundle: PresentationResources.bundle) + .font(.title3.bold()) + .padding(.horizontal, 16) + + if store.isRecentTodosLoading && store.recentTodos.isEmpty { + LoadingView() + .frame(maxWidth: .infinity, minHeight: 80) + .background(Color.surface, in: RoundedRectangle(cornerRadius: 16)) + } else if store.recentTodos.isEmpty { + Text("profile_recent_empty", bundle: PresentationResources.bundle) + .font(.callout) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity, minHeight: 80) + .background(Color.surface, in: RoundedRectangle(cornerRadius: 16)) + } else { + VStack(spacing: 12) { + ForEach(store.recentTodos) { todo in + Button { + onSelectTodo(todo.id) + } label: { + RecentTodoCard(todo: todo) + .todoDetailPreview(todoId: todo.id) + } + .buttonStyle(.plain) + } + } + } + } + } +} + +private struct RecentTodoCard: View { + @ScaledMetric(relativeTo: .title3) private var iconSize = CGFloat(44) + let todo: RecentTodoItem + + var body: some View { + let category = TodoCategoryItem(from: todo.category) + HStack(spacing: 12) { + Image(systemName: category.symbolName) + .font(.title3.weight(.semibold)) + .foregroundStyle(category.color) + .frame(width: iconSize, height: iconSize) + .background(category.color.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 8) { + Text(todo.title) + .foregroundStyle(Color.primary) + .font(.headline) + .lineLimit(2) + if todo.isPinned { + Image(systemName: "star.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.accent) + } + } + + HStack(spacing: 6) { + Text(verbatim: "#\(todo.number)") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.accent) + Text(category.localizedName) + .font(.caption) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + Spacer(minLength: 0) + RelativeTimeText( + date: todo.updatedAt, + bodyFont: .caption2, + bodyColor: .textTertiary + ) + .fixedSize(horizontal: true, vertical: false) + } + + if !todo.tags.isEmpty { + TagList(todo.tags, lineLimit: 1) + } + } + + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.callout.bold()) + .foregroundStyle(Color.textTertiary) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color.surface, in: RoundedRectangle(cornerRadius: 16)) + .contentShape(.rect) + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Profile/UserInfoCard.swift b/Application/Presentation/ProfileTab/Sources/Profile/UserInfoCard.swift new file mode 100644 index 00000000..d9ad180e --- /dev/null +++ b/Application/Presentation/ProfileTab/Sources/Profile/UserInfoCard.swift @@ -0,0 +1,104 @@ +// +// UserInfoCard.swift +// ProfileTab +// +// Created by opfic on 9/24/26. +// + +import SwiftUI +import PresentationShared + +struct UserInfoCard: View { + @Bindable var store: StoreOf + @FocusState private var focused: Bool + let isSelected: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 16) { + Group { + if let data = store.avatarImageData?.data, + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFill() + .symbolRenderingMode(.palette) + .foregroundStyle(Color.onPrimaryContainer, Color.primaryContainer) + } + } + .frame(width: 64, height: 64) + .clipShape(.circle) + .transaction { $0.animation = nil } + + VStack(alignment: .leading, spacing: 4) { + Text(store.name) + .font(.title2) + .bold() + Text(store.email) + .font(.caption) + .foregroundStyle(Color.textSecondary) + } + Spacer(minLength: 0) + } + + HStack { + HStack { + Image(systemName: "face.smiling") + .foregroundStyle(Color.accent) + TextField( + text: $store.statusMessage + ) { + Text("profile_status_placeholder", bundle: PresentationResources.bundle) + .foregroundStyle(Color.textTertiary) + } + .foregroundStyle(Color.textSecondary) + .tint(Color.accent) + .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) + .focused($focused) + .disabled(!store.isNetworkConnected) + + if !store.statusMessage.isEmpty, + store.showDoneButton { + Button { + store.send(.tapResetStatusMessageButton) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.accent) + } + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.accent.opacity(0.08)) + ) + if store.showDoneButton { + Button { + focused = false + store.send(.willUpdateStatusMessage) + } label: { + Text("profile_done", bundle: PresentationResources.bundle) + } + .tint(Color.accent) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .opacity(store.isNetworkConnected ? 1 : 0.7) + } + .padding(16) + .background(Color.surface, in: RoundedRectangle(cornerRadius: 16)) + .onChange(of: isSelected, initial: true) { _, isSelected in + if !isSelected { + focused = false + } + } + .onChange(of: focused) { _, focused in + store.send(.updateStatusTextFieldFocus(focused), animation: .default) + } + } +} diff --git a/Application/Presentation/ProfileTab/Tests/Profile/ProfileFeatureTests.swift b/Application/Presentation/ProfileTab/Tests/Profile/ProfileFeatureTests.swift index 8969dcbf..2bd3a6f3 100644 --- a/Application/Presentation/ProfileTab/Tests/Profile/ProfileFeatureTests.swift +++ b/Application/Presentation/ProfileTab/Tests/Profile/ProfileFeatureTests.swift @@ -15,6 +15,79 @@ import Domain @MainActor struct ProfileFeatureTests { + @Test("ProfileFeature는 프로필 조회와 함께 개발 목표를 조회한다") + func ProfileFeature는_프로필_조회와_함께_개발_목표를_조회한다() async { + let spy = FetchDevelopmentGoalsUseCaseSpy() + let adapter = StoreTestAdapter(fetchDevelopmentGoalsUseCase: spy) + + await adapter.fetchData() + + #expect(spy.queries == [.init()]) + #expect(adapter.hasDevelopmentGoalsLoaded) + } + + @Test("ProfileFeature는 전체 개발 목표를 상태 제한 없이 조회한다") + func ProfileFeature는_전체_개발_목표를_상태_제한_없이_조회한다() async throws { + let spy = FetchDevelopmentGoalsUseCaseSpy() + let now = Date() + let goals = try [ + DevelopmentGoal( + id: "in-progress", + title: "진행 중인 목표", + description: "", + status: .inProgress, + createdAt: now, + updatedAt: now, + completedAt: nil + ), + DevelopmentGoal( + id: "completed", + title: "완료한 목표", + description: "", + status: .completed, + createdAt: now, + updatedAt: now, + completedAt: now + ), + DevelopmentGoal( + id: "archived", + title: "보관한 목표", + description: "", + status: .archived, + createdAt: now, + updatedAt: now, + completedAt: nil + ) + ] + spy.result = .success(goals) + let adapter = StoreTestAdapter(fetchDevelopmentGoalsUseCase: spy) + + await adapter.retryDevelopmentGoals() + + #expect(spy.queries == [.init()]) + #expect(adapter.developmentGoals == goals) + #expect(adapter.hasDevelopmentGoalsLoaded) + #expect(!adapter.hasDevelopmentGoalsLoadFailure) + } + + @Test("ProfileFeature는 개발 목표 조회 실패 뒤 재시도할 수 있다") + func ProfileFeature는_개발_목표_조회_실패_뒤_재시도할_수_있다() async { + let spy = FetchDevelopmentGoalsUseCaseSpy() + spy.result = .failure(TestError()) + let adapter = StoreTestAdapter(fetchDevelopmentGoalsUseCase: spy) + + await adapter.retryDevelopmentGoals() + #expect(!adapter.hasDevelopmentGoalsLoaded) + #expect(adapter.hasDevelopmentGoalsLoadFailure) + + spy.result = .success([]) + await adapter.retryDevelopmentGoals() + #expect(adapter.hasDevelopmentGoalsLoaded) + #expect(!adapter.hasDevelopmentGoalsLoadFailure) + #expect(adapter.developmentGoals.isEmpty) + #expect(spy.queries.count == 2) + } + @Test("ProfileFeature는 최근 수정한 Todo를 최대 5개까지 카테고리 설정과 함께 갱신한다") func ProfileFeature는_최근_수정한_Todo를_최대_5개까지_카테고리_설정과_함께_갱신한다() async { let category = TodoCategory.user( @@ -162,6 +235,16 @@ private final class FetchTodosUseCaseSpy: FetchTodosUseCase { } } +private final class FetchDevelopmentGoalsUseCaseSpy: FetchDevelopmentGoalsUseCase { + private(set) var queries = [DevelopmentGoal.Query]() + var result: Result<[DevelopmentGoal], Error> = .success([]) + + func execute(_ query: DevelopmentGoal.Query) async throws -> [DevelopmentGoal] { + queries.append(query) + return try result.get() + } +} + private final class FetchTodoCategoryPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCase { var error: Error? var preferences = [TodoCategoryPreference]() @@ -246,11 +329,15 @@ private struct StoreTestAdapter { var isLoading: Bool { store.state.isLoading } var isRecentTodosLoading: Bool { store.state.isRecentTodosLoading } var recentTodos: [RecentTodoItem] { store.state.recentTodos } + var developmentGoals: [DevelopmentGoal] { store.state.developmentGoals } + var hasDevelopmentGoalsLoaded: Bool { store.state.hasDevelopmentGoalsLoaded } + var hasDevelopmentGoalsLoadFailure: Bool { store.state.hasDevelopmentGoalsLoadFailure } var selectedActivityKinds: Set { store.state.selectedActivityKinds } init( fetchProfileImageDataUseCase: FetchProfileImageDataUseCase = FetchProfileImageDataUseCaseSpy(data: Data()), fetchTodosUseCase: FetchTodosUseCase = FetchTodosUseCaseSpy(), + fetchDevelopmentGoalsUseCase: FetchDevelopmentGoalsUseCase = FetchDevelopmentGoalsUseCaseSpy(), fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = FetchTodoCategoryPreferencesUseCaseSpy(), todoMutationEventBus: TodoMutationEventBus = TodoMutationEventBusSpy(), upsertStatusMessageUseCase: UpsertStatusMessageUseCase = UpsertStatusMessageUseCaseSpy(), @@ -272,6 +359,7 @@ private struct StoreTestAdapter { ) $0.profileFetchImageDataUseCase = fetchProfileImageDataUseCase $0.profileFetchTodosUseCase = fetchTodosUseCase + $0.profileFetchDevelopmentGoalsUseCase = fetchDevelopmentGoalsUseCase $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase $0.profileTodoMutationEventBus = todoMutationEventBus $0.profileUpsertStatusMessageUseCase = upsertStatusMessageUseCase @@ -288,6 +376,12 @@ private struct StoreTestAdapter { await drainReceivedActions() } + func retryDevelopmentGoals() async { + let task = await store.send(.retryDevelopmentGoals) + await task.finish() + await drainReceivedActions() + } + func startObserving() async { await store.send(.startObserving) await Task.yield() diff --git a/docs/hitmap.png b/docs/hitmap.png index d6c9c548..fa10c4bd 100644 Binary files a/docs/hitmap.png and b/docs/hitmap.png differ