Skip to content
Merged
2 changes: 2 additions & 0 deletions DevLog/Data/DTO/TodoDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct TodoRequest: Encodable {
let content: String
let createdAt: Date
let updatedAt: Date
let completedAt: Date?
let dueDate: Date?
let tags: [String]
let kind: TodoKind
Expand All @@ -31,6 +32,7 @@ struct TodoResponse {
let content: String
let createdAt: Date
let updatedAt: Date
let completedAt: Date?
let dueDate: Date?
let tags: [String]
let kind: String
Expand Down
2 changes: 2 additions & 0 deletions DevLog/Data/Mapper/TodoMapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ extension TodoRequest {
content: entity.content,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
completedAt: entity.completedAt,
dueDate: entity.dueDate,
tags: entity.tags,
kind: entity.kind
Expand All @@ -38,6 +39,7 @@ extension TodoResponse {
content: self.content,
createdAt: self.createdAt,
updatedAt: self.updatedAt,
completedAt: self.completedAt,
dueDate: self.dueDate,
tags: self.tags,
kind: kind
Expand Down
1 change: 1 addition & 0 deletions DevLog/Domain/Entity/Todo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct Todo: Identifiable, Hashable {
var content: String // 할 일의 설명
var createdAt: Date // 할 일 생성 날짜
var updatedAt: Date // 할 일 수정 날짜
var completedAt: Date? // 할 일 완료 날짜
var dueDate: Date? // 할 일의 마감 날짜 (선택 사항)
var tags: [String] // 할 일에 연결된 태그들
var kind: TodoKind // 할 일의 종류
Expand Down
6 changes: 6 additions & 0 deletions DevLog/Infra/Service/TodoService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ final class TodoService {
let docRef = collection.document(request.id)
var data = try encoder.encode(request)
data.removeValue(forKey: TodoFieldKey.id.rawValue)
if request.completedAt == nil {
data[TodoFieldKey.completedAt.rawValue] = NSNull()
}
if request.dueDate == nil {
data[TodoFieldKey.dueDate.rawValue] = NSNull()
}
Expand Down Expand Up @@ -217,6 +220,7 @@ private extension TodoService {
return nil
}

let completedAt = (data[TodoFieldKey.completedAt.rawValue] as? Timestamp)?.dateValue()
let dueDate = (data[TodoFieldKey.dueDate.rawValue] as? Timestamp)?.dateValue()
return TodoResponse(
id: documentID,
Expand All @@ -227,6 +231,7 @@ private extension TodoService {
content: content,
createdAt: createdAtTimestamp.dateValue(),
updatedAt: updatedAtTimestamp.dateValue(),
completedAt: completedAt,
dueDate: dueDate,
tags: tags,
kind: kind
Expand All @@ -242,6 +247,7 @@ private extension TodoService {
case content
case createdAt
case updatedAt
case completedAt
case dueDate
case tags
case kind
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@
import Foundation

struct ProfileCompletionQuarter: Identifiable, Hashable {
var id: Date { quarterStart }
let quarterStart: Date
let months: [ProfileCompletionMonth]

var id: Date { quarterStart }
var maxCount: Int {
months
.flatMap { $0.weeks }
.flatMap { $0 }
.filter { $0.isInMonth }
.map { $0.createdCount + $0.completedCount }
.max() ?? 0
}
}
2 changes: 2 additions & 0 deletions DevLog/Presentation/Structure/TodoListItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ struct TodoListItem: Identifiable, Hashable {
let title: String
let tags: [String]
let isPinned: Bool
let isCompleted: Bool

init(from todo: Todo) {
self.id = todo.id
self.title = todo.title
self.tags = todo.tags
self.isPinned = todo.isPinned
self.isCompleted = todo.isCompleted
}
}
6 changes: 3 additions & 3 deletions DevLog/Presentation/ViewModel/ProfileViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ private extension ProfileViewModel {

for todo in todos {
let createdDay = calendar.startOfDay(for: todo.createdAt)
let completedDay = todo.isCompleted ? calendar.startOfDay(for: todo.updatedAt) : nil
let completedDay = todo.completedAt.map { calendar.startOfDay(for: $0) }

activitiesByDate[createdDay, default: []].append(
ProfileSelectedDayActivity(
Expand Down Expand Up @@ -305,8 +305,8 @@ private extension ProfileViewModel {
let createdDay = calendar.startOfDay(for: todo.createdAt)
dailyCreatedCount[createdDay, default: 0] += 1

if todo.isCompleted {
let completedDay = calendar.startOfDay(for: todo.updatedAt)
if let completedAt = todo.completedAt {
let completedDay = calendar.startOfDay(for: completedAt)
dailyCompletedCount[completedDay, default: 0] += 1
}
}
Expand Down
4 changes: 4 additions & 0 deletions DevLog/Presentation/ViewModel/TodoEditorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ final class TodoEditorViewModel: Store {
private let isCompleted: Bool
private let isChecked: Bool
private let createdAt: Date?
private let completedAt: Date?
private let kind: TodoKind

// 새로운 Todo 생성용 생성자
Expand All @@ -59,6 +60,7 @@ final class TodoEditorViewModel: Store {
self.isCompleted = false
self.isChecked = false
self.createdAt = nil
self.completedAt = nil
self.kind = kind
}

Expand All @@ -70,6 +72,7 @@ final class TodoEditorViewModel: Store {
self.isCompleted = todo.isCompleted
self.isChecked = todo.isChecked
self.createdAt = todo.createdAt
self.completedAt = todo.completedAt
self.kind = todo.kind
state.title = todo.title
state.content = todo.content
Expand Down Expand Up @@ -141,6 +144,7 @@ extension TodoEditorViewModel {
content: state.content,
createdAt: self.createdAt ?? date,
updatedAt: date,
completedAt: self.completedAt,
dueDate: state.dueDate,
tags: state.tags.map { $0 },
kind: self.kind
Expand Down
29 changes: 27 additions & 2 deletions DevLog/Presentation/ViewModel/TodoListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ final class TodoListViewModel: Store {
case setShowEditor(Bool)
case swipeTodo(TodoListItem)
case tapFilterOption(FilterOption)
case tapToggleCompleted(TodoListItem)
case tapTogglePinned(TodoListItem)
case undoDelete

Expand All @@ -50,6 +51,7 @@ final class TodoListViewModel: Store {
case upsertTodo(Todo)

// Run
case didToggleCompleted(TodoListItem)
case didTogglePinned(TodoListItem)
case setLoading(Bool)
case appendTodos([TodoListItem], nextCursor: TodoCursor?)
Expand All @@ -62,6 +64,7 @@ final class TodoListViewModel: Store {
case loadNextPage
case upsert(Todo)
case delete(String)
case toggleCompleted(TodoListItem)
case togglePinned(TodoListItem)
}

Expand Down Expand Up @@ -91,13 +94,13 @@ final class TodoListViewModel: Store {
var effects: [SideEffect] = []

switch action {
case .refresh, .setAlert, .setShowEditor, .swipeTodo, .tapFilterOption, .tapTogglePinned, .undoDelete:
case .refresh, .setAlert, .setShowEditor, .swipeTodo, .tapFilterOption, .tapToggleCompleted, .tapTogglePinned, .undoDelete:
effects = reduceByUser(action, state: &state)

case .confirmDelete, .onAppear, .loadNextPage, .setScope, .setSearchText, .setToast, .upsertTodo:
effects = reduceByView(action, state: &state)

case .didTogglePinned, .setLoading, .appendTodos, .resetPagination, .setHasMore:
case .didToggleCompleted, .didTogglePinned, .setLoading, .appendTodos, .resetPagination, .setHasMore:
effects = reduceByRun(action, state: &state)
}

Expand Down Expand Up @@ -145,6 +148,22 @@ final class TodoListViewModel: Store {
send(.setAlert(true))
}
}
case .toggleCompleted(let item):
Task {
do {
defer { send(.setLoading(false)) }
send(.setLoading(true))
var todo = try await fetchTodoByIDUseCase.execute(item.id)
let now = Date()
todo.isCompleted.toggle()
todo.completedAt = todo.isCompleted ? now : nil
todo.updatedAt = now
try await upsertTodoUseCase.execute(todo)
send(.didToggleCompleted(TodoListItem(from: todo)))
} catch {
send(.setAlert(true))
}
}
case .togglePinned(let item):
Task {
do {
Expand Down Expand Up @@ -195,6 +214,8 @@ private extension TodoListViewModel {
return effects
case .tapFilterOption(let option):
state.filterOption = option
case .tapToggleCompleted(let todo):
return [.toggleCompleted(todo)]
case .tapTogglePinned(let todo):
return [.togglePinned(todo)]
case .undoDelete:
Expand Down Expand Up @@ -238,6 +259,10 @@ private extension TodoListViewModel {

func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] {
switch action {
case .didToggleCompleted(let todo):
if let index = state.todos.firstIndex(where: { $0.id == todo.id }) {
state.todos[index] = todo
}
case .didTogglePinned(let todo):
if let index = state.todos.firstIndex(where: { $0.id == todo.id }) {
state.todos[index] = todo
Expand Down
5 changes: 5 additions & 0 deletions DevLog/UI/Common/Component/TodoItemRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ struct TodoItemRow: View {
.font(.headline)
.foregroundStyle(.orange)
}
if item.isCompleted {
Image(systemName: "checkmark.circle.fill")
.font(.headline)
.foregroundStyle(.green)
}
Text(item.title)
.font(.headline)
.foregroundStyle(Color(.label))
Expand Down
7 changes: 6 additions & 1 deletion DevLog/UI/Home/TodoListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,19 @@ struct TodoListView: View {
Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")")
}
.tint(Color.orange)
Button {
viewModel.send(.tapToggleCompleted(todo))
} label: {
Image(systemName: todo.isCompleted ? "arrow.uturn.backward" : "checkmark")
}
.tint(Color.green)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive, action: {
viewModel.send(.swipeTodo(todo))
}) {
Image(systemName: "trash")
}

}
}
.listStyle(.plain)
Expand Down
8 changes: 2 additions & 6 deletions DevLog/UI/Profile/ProfileHeatmapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ struct ProfileHeatmapView: View {
ForEach(Array(zip(months.indices, months)), id: \.1) { index, month in
MonthCompactHeatmapView(
month: month,
maxCount: quarter.maxCount,
selectedActivityTypes: selectedActivityTypes,
selectedDay: selectedDay,
onSelectDay: onSelectDay
Expand Down Expand Up @@ -64,6 +65,7 @@ struct ProfileHeatmapView: View {
private struct MonthCompactHeatmapView: View {
@Environment(\.colorScheme) private var colorScheme
let month: ProfileCompletionMonth
let maxCount: Int
let selectedActivityTypes: Set<ProfileActivityType>
let selectedDay: ProfileCompletionDay?
let onSelectDay: (ProfileCompletionDay) -> Void
Expand All @@ -72,12 +74,6 @@ private struct MonthCompactHeatmapView: View {
private let cellSpacing: CGFloat = 4

var body: some View {
let maxCount = month.weeks
.flatMap { $0 }
.filter { $0.isInMonth }
.map(dayCount(for:))
.max() ?? 0

VStack(alignment: .leading, spacing: 6) {
Text(month.monthStart.formatted(.dateTime.month(.abbreviated)))
.frame(height: cellSize)
Expand Down
1 change: 1 addition & 0 deletions DevLog/UI/Setting/PushNotificationSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct PushNotificationSettingsView: View {
)) {
Text("푸시 알람 활성화")
}
.tint(.blue)
}, footer: {
Text("설정에서의 푸시 알람 설정과 별개입니다.")
})
Expand Down