Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,19 @@ private extension AppGraph {
}

func prepareHomeDependencies(_ dependencies: inout DependencyValues) {
HomePresentationDependencyPreparation.prepareDevelopmentGoal(
&dependencies,
fetchGoalsUseCase: developmentGraphSet
.developmentGoalUseCaseGraph
.fetchDevelopmentGoalsUseCase,
fetchRecordsUseCase: developmentGraphSet
.developmentRecordQueryUseCaseGraph
.fetchDevelopmentRecordsUseCase,
fetchRecordVersionUseCase: developmentGraphSet
.developmentRecordQueryUseCaseGraph
.fetchDevelopmentRecordVersionUseCase,
fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase
)
HomePresentationDependencyPreparation.prepareTodoCategory(
&dependencies,
updateTodoCategoryPreferencesUseCase: todoGraphSet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,35 +141,34 @@ private struct GoalCreateDescriptionEditor: View {
VStack(spacing: 16) {
GoalCreateModePicker(store: store, focusedField: _focusedField)

switch store.selectedTab {
case .write:
TextEditor(text: $store.markdownContent)
.focused($focusedField, equals: .content)
.font(.body)
.scrollContentBackground(.hidden)
.padding(12)
.frame(minHeight: 340, alignment: .topLeading)
.background(Color.surfaceSecondary, in: .rect(cornerRadius: 16))
case .preview:
Group {
if store.markdownContent.isEmpty {
ContentUnavailableView(
RecordPresentation.text("development_goal_create_preview_empty_title"),
systemImage: "doc.text.magnifyingglass",
description: Text(
RecordPresentation.text(
"development_goal_create_preview_empty_message"
)
)
)
} else {
MarkdownContentView(content: store.markdownContent)
.padding(.vertical, 16)
Group {
switch store.selectedTab {
case .write:
TextEditor(text: $store.markdownContent)
.focused($focusedField, equals: .content)
.font(.body)
.scrollContentBackground(.hidden)
.padding(12)
case .preview:
Group {
if store.markdownContent.isEmpty {
ContentUnavailableView {
Text(RecordPresentation.text("development_goal_create_preview_empty_title"))
.bold()
} description: {
Text(RecordPresentation.text("development_goal_create_preview_empty_message"))
}
.frame(maxWidth: .infinity)
.frame(height: 120)
} else {
MarkdownContentView(content: store.markdownContent)
.padding(.vertical, 16)
}
}
}
.frame(minHeight: 340)
.background(Color.surfaceSecondary, in: .rect(cornerRadius: 16))
}
.frame(minHeight: 120)
.background(Color.surfaceSecondary, in: .rect(cornerRadius: 16))

Text(RecordPresentation.text("development_goal_create_markdown_hint"))
.font(.caption)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,3 @@ private enum Field: Hashable {
case title
case content
}

#Preview("새 개발 기록") {
RecordEditorView(
goalId: "preview-goal",
goalTitle: "개발 기록의 버전 이력 완성"
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// HomeDevelopmentGoalItem.swift
// HomeTab
//
// Created by opfic on 9/20/26.
//

import Domain

struct HomeDevelopmentGoalItem: Equatable, Identifiable {
let goal: DevelopmentGoal
let recentRecord: DevelopmentRecord.Version?
let todoProgress: HomeDevelopmentGoalTodoProgress

var id: String { goal.id }

init(
goal: DevelopmentGoal,
recentRecord: DevelopmentRecord.Version?,
todoProgress: HomeDevelopmentGoalTodoProgress = .empty
) {
self.goal = goal
self.recentRecord = recentRecord
self.todoProgress = todoProgress
}
}

struct HomeDevelopmentGoalTodoProgress: Equatable {
let completedCount: Int
let totalCount: Int

static let empty = Self(completedCount: 0, totalCount: 0)

var fraction: Double {
guard totalCount != 0 else { return 0 }
return Double(completedCount) / Double(totalCount)
}

var percentage: Int {
guard totalCount != 0 else { return 0 }
return (completedCount * 100 + totalCount / 2) / totalCount
}

var isEmpty: Bool {
totalCount == 0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
//
// HomeDevelopmentGoalSection.swift
// HomeTab
//
// Created by opfic on 9/20/26.
//

import SwiftUI
import PresentationShared

struct HomeDevelopmentGoalSection: View {
let items: [HomeDevelopmentGoalItem]
let isLoading: Bool
let hasLoaded: Bool
let hasLoadFailure: Bool
let onCreate: () -> Void
let onSelect: (HomeDevelopmentGoalItem) -> Void
let onRetry: () -> Void

var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
content
}
.padding(.top, 8)
}

@ViewBuilder
private var content: some View {
if !hasLoaded && isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
} else if !hasLoaded && hasLoadFailure {
ContentUnavailableView {
Label(
String(localized: "common_error_title", bundle: PresentationResources.bundle),
systemImage: "exclamationmark.triangle"
)
} description: {
Text(String(localized: "common_error_message", bundle: PresentationResources.bundle))
} actions: {
Button(String(localized: "development_record_timeline_retry", bundle: PresentationResources.bundle)) {
onRetry()
}
.buttonStyle(.borderedProminent)
}
} else if items.isEmpty {
ContentUnavailableView {
Label(
String(
localized: "home_development_goal_empty_title",
bundle: PresentationResources.bundle
),
systemImage: "flag.checkered"
)
} description: {
Text(
String(
localized: "home_development_goal_empty_message",
bundle: PresentationResources.bundle
)
)
}
} else {
LazyVStack(spacing: 16) {
ForEach(items) { item in
Button {
onSelect(item)
} label: {
HomeDevelopmentGoalCard(item: item)
}
.buttonStyle(.plain)
}
}
}
}

private var header: some View {
HStack(spacing: 8) {
Text("home_development_goal_section_title", bundle: PresentationResources.bundle)
.font(.title2)
.foregroundStyle(Color.primary)
if hasLoaded {
Text(
String.localizedStringWithFormat(
String(localized: "home_development_goal_count_format", bundle: PresentationResources.bundle),
items.count
)
)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.accent)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.accent.opacity(0.1), in: .capsule)
}
Spacer()
Button(action: onCreate) {
Text("home_development_goal_create", bundle: PresentationResources.bundle)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.textSecondary)
}
.accessibilityLabel(
String(localized: "development_goal_create_title", bundle: PresentationResources.bundle)
)
}
.accessibilityElement(children: .combine)
}
}

private struct HomeDevelopmentGoalCard: View {
let item: HomeDevelopmentGoalItem

var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
HomeDevelopmentGoalStatusBadge()
Spacer(minLength: 12)
if let recentRecord = item.recentRecord {
Text(recentRecord.confirmedAt, format: .dateTime.month().day().hour().minute())
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
}

Text(item.goal.title)
.font(.title3.weight(.semibold))
.foregroundStyle(Color.primary)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)

if !item.goal.description.isEmpty {
Text(item.goal.description)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}

HomeDevelopmentTodoProgressView(progress: item.todoProgress)

Divider()

if let recentRecord = item.recentRecord {
VStack(alignment: .leading, spacing: 3) {
Text("home_development_goal_recent_record", bundle: PresentationResources.bundle)
.font(.caption)
.foregroundStyle(Color.textTertiary)
Text(recentRecord.title)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.primary)
.lineLimit(1)
}
} else {
Text("development_record_empty_title", bundle: PresentationResources.bundle)
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
}
.padding(18)
.background(Color.surface, in: .rect(cornerRadius: 24))
.contentShape(.rect)
.accessibilityElement(children: .combine)
}
}

struct HomeDevelopmentTodoProgressView: View {
let progress: HomeDevelopmentGoalTodoProgress

var body: some View {
if progress.isEmpty {
Text("home_development_goal_todo_empty", bundle: PresentationResources.bundle)
.font(.caption)
.foregroundStyle(Color.textTertiary)
} else {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(
String.localizedStringWithFormat(
String(
localized: "home_development_goal_todo_progress_format",
bundle: PresentationResources.bundle
),
progress.completedCount,
progress.totalCount
)
)
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
Spacer(minLength: 8)
Text(
String.localizedStringWithFormat(
String(
localized: "home_development_goal_todo_progress_percent_format",
bundle: PresentationResources.bundle
),
progress.percentage
)
)
.font(.caption.weight(.semibold))
.foregroundStyle(Color.accent)
}

GeometryReader { proxy in
Capsule()
.fill(Color.border.opacity(0.6))
.overlay(alignment: .leading) {
Capsule()
.fill(Color.accent)
.frame(width: proxy.size.width * progress.fraction)
}
}
.frame(height: 8)
}
.accessibilityElement(children: .combine)
}
}
}

private struct HomeDevelopmentGoalStatusBadge: View {
var body: some View {
Label {
Text("development_goal_status_in_progress", bundle: PresentationResources.bundle)
} icon: {
Image(systemName: "clock")
}
.font(.caption.weight(.semibold))
.foregroundStyle(Color.accent)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.accent.opacity(0.1), in: .capsule)
}
}

enum HomeGoalPresentation: Identifiable {
case create
case detail(String)

var id: String {
switch self {
case .create:
"create"
case .detail(let goalID):
goalID
}
}
}
Loading
Loading