Skip to content

Commit 30dcaf4

Browse files
authored
[#159] TodoListView에서 Todo의 완료 액션을 줄 수 있는 스와이프 액션을 구현한다 (#163)
* feat: 스와이프 액션으로 완료 토글 구현 * feat: 완료 시점을 저장하도록 구현 * refactor: maxCount를 달 단위가 아닌, 분기 단위로 수정 * ui: 시그니처 색인 Color.blue로 통일 * ui: 완료된 Todo이면 초록색 체크마크가 보임 * ui: 스와이프 액션을 leading에서 하도록 수정 및 색 변경 * Delete .claude/settings.local.json * feat: Todo가 완료되었을 때 updatedAt도 동일한 시간으로 업데이트 하기
1 parent c16aec7 commit 30dcaf4

13 files changed

Lines changed: 70 additions & 14 deletions

File tree

DevLog/Data/DTO/TodoDTO.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ struct TodoRequest: Encodable {
1616
let content: String
1717
let createdAt: Date
1818
let updatedAt: Date
19+
let completedAt: Date?
1920
let dueDate: Date?
2021
let tags: [String]
2122
let kind: TodoKind
@@ -31,6 +32,7 @@ struct TodoResponse {
3132
let content: String
3233
let createdAt: Date
3334
let updatedAt: Date
35+
let completedAt: Date?
3436
let dueDate: Date?
3537
let tags: [String]
3638
let kind: String

DevLog/Data/Mapper/TodoMapping.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ extension TodoRequest {
1616
content: entity.content,
1717
createdAt: entity.createdAt,
1818
updatedAt: entity.updatedAt,
19+
completedAt: entity.completedAt,
1920
dueDate: entity.dueDate,
2021
tags: entity.tags,
2122
kind: entity.kind
@@ -38,6 +39,7 @@ extension TodoResponse {
3839
content: self.content,
3940
createdAt: self.createdAt,
4041
updatedAt: self.updatedAt,
42+
completedAt: self.completedAt,
4143
dueDate: self.dueDate,
4244
tags: self.tags,
4345
kind: kind

DevLog/Domain/Entity/Todo.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ struct Todo: Identifiable, Hashable {
1616
var content: String // 할 일의 설명
1717
var createdAt: Date // 할 일 생성 날짜
1818
var updatedAt: Date // 할 일 수정 날짜
19+
var completedAt: Date? // 할 일 완료 날짜
1920
var dueDate: Date? // 할 일의 마감 날짜 (선택 사항)
2021
var tags: [String] // 할 일에 연결된 태그들
2122
var kind: TodoKind // 할 일의 종류

DevLog/Infra/Service/TodoService.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ final class TodoService {
130130
let docRef = collection.document(request.id)
131131
var data = try encoder.encode(request)
132132
data.removeValue(forKey: TodoFieldKey.id.rawValue)
133+
if request.completedAt == nil {
134+
data[TodoFieldKey.completedAt.rawValue] = NSNull()
135+
}
133136
if request.dueDate == nil {
134137
data[TodoFieldKey.dueDate.rawValue] = NSNull()
135138
}
@@ -217,6 +220,7 @@ private extension TodoService {
217220
return nil
218221
}
219222

223+
let completedAt = (data[TodoFieldKey.completedAt.rawValue] as? Timestamp)?.dateValue()
220224
let dueDate = (data[TodoFieldKey.dueDate.rawValue] as? Timestamp)?.dateValue()
221225
return TodoResponse(
222226
id: documentID,
@@ -227,6 +231,7 @@ private extension TodoService {
227231
content: content,
228232
createdAt: createdAtTimestamp.dateValue(),
229233
updatedAt: updatedAtTimestamp.dateValue(),
234+
completedAt: completedAt,
230235
dueDate: dueDate,
231236
tags: tags,
232237
kind: kind
@@ -242,6 +247,7 @@ private extension TodoService {
242247
case content
243248
case createdAt
244249
case updatedAt
250+
case completedAt
245251
case dueDate
246252
case tags
247253
case kind

DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@
88
import Foundation
99

1010
struct ProfileCompletionQuarter: Identifiable, Hashable {
11+
var id: Date { quarterStart }
1112
let quarterStart: Date
1213
let months: [ProfileCompletionMonth]
13-
14-
var id: Date { quarterStart }
14+
var maxCount: Int {
15+
months
16+
.flatMap { $0.weeks }
17+
.flatMap { $0 }
18+
.filter { $0.isInMonth }
19+
.map { $0.createdCount + $0.completedCount }
20+
.max() ?? 0
21+
}
1522
}

DevLog/Presentation/Structure/TodoListItem.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ struct TodoListItem: Identifiable, Hashable {
1212
let title: String
1313
let tags: [String]
1414
let isPinned: Bool
15+
let isCompleted: Bool
1516

1617
init(from todo: Todo) {
1718
self.id = todo.id
1819
self.title = todo.title
1920
self.tags = todo.tags
2021
self.isPinned = todo.isPinned
22+
self.isCompleted = todo.isCompleted
2123
}
2224
}

DevLog/Presentation/ViewModel/ProfileViewModel.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ private extension ProfileViewModel {
241241

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

246246
activitiesByDate[createdDay, default: []].append(
247247
ProfileSelectedDayActivity(
@@ -305,8 +305,8 @@ private extension ProfileViewModel {
305305
let createdDay = calendar.startOfDay(for: todo.createdAt)
306306
dailyCreatedCount[createdDay, default: 0] += 1
307307

308-
if todo.isCompleted {
309-
let completedDay = calendar.startOfDay(for: todo.updatedAt)
308+
if let completedAt = todo.completedAt {
309+
let completedDay = calendar.startOfDay(for: completedAt)
310310
dailyCompletedCount[completedDay, default: 0] += 1
311311
}
312312
}

DevLog/Presentation/ViewModel/TodoEditorViewModel.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ final class TodoEditorViewModel: Store {
4949
private let isCompleted: Bool
5050
private let isChecked: Bool
5151
private let createdAt: Date?
52+
private let completedAt: Date?
5253
private let kind: TodoKind
5354

5455
// 새로운 Todo 생성용 생성자
@@ -59,6 +60,7 @@ final class TodoEditorViewModel: Store {
5960
self.isCompleted = false
6061
self.isChecked = false
6162
self.createdAt = nil
63+
self.completedAt = nil
6264
self.kind = kind
6365
}
6466

@@ -70,6 +72,7 @@ final class TodoEditorViewModel: Store {
7072
self.isCompleted = todo.isCompleted
7173
self.isChecked = todo.isChecked
7274
self.createdAt = todo.createdAt
75+
self.completedAt = todo.completedAt
7376
self.kind = todo.kind
7477
state.title = todo.title
7578
state.content = todo.content
@@ -141,6 +144,7 @@ extension TodoEditorViewModel {
141144
content: state.content,
142145
createdAt: self.createdAt ?? date,
143146
updatedAt: date,
147+
completedAt: self.completedAt,
144148
dueDate: state.dueDate,
145149
tags: state.tags.map { $0 },
146150
kind: self.kind

DevLog/Presentation/ViewModel/TodoListViewModel.swift

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ final class TodoListViewModel: Store {
3737
case setShowEditor(Bool)
3838
case swipeTodo(TodoListItem)
3939
case tapFilterOption(FilterOption)
40+
case tapToggleCompleted(TodoListItem)
4041
case tapTogglePinned(TodoListItem)
4142
case undoDelete
4243

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

5253
// Run
54+
case didToggleCompleted(TodoListItem)
5355
case didTogglePinned(TodoListItem)
5456
case setLoading(Bool)
5557
case appendTodos([TodoListItem], nextCursor: TodoCursor?)
@@ -62,6 +64,7 @@ final class TodoListViewModel: Store {
6264
case loadNextPage
6365
case upsert(Todo)
6466
case delete(String)
67+
case toggleCompleted(TodoListItem)
6568
case togglePinned(TodoListItem)
6669
}
6770

@@ -91,13 +94,13 @@ final class TodoListViewModel: Store {
9194
var effects: [SideEffect] = []
9295

9396
switch action {
94-
case .refresh, .setAlert, .setShowEditor, .swipeTodo, .tapFilterOption, .tapTogglePinned, .undoDelete:
97+
case .refresh, .setAlert, .setShowEditor, .swipeTodo, .tapFilterOption, .tapToggleCompleted, .tapTogglePinned, .undoDelete:
9598
effects = reduceByUser(action, state: &state)
9699

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

100-
case .didTogglePinned, .setLoading, .appendTodos, .resetPagination, .setHasMore:
103+
case .didToggleCompleted, .didTogglePinned, .setLoading, .appendTodos, .resetPagination, .setHasMore:
101104
effects = reduceByRun(action, state: &state)
102105
}
103106

@@ -145,6 +148,22 @@ final class TodoListViewModel: Store {
145148
send(.setAlert(true))
146149
}
147150
}
151+
case .toggleCompleted(let item):
152+
Task {
153+
do {
154+
defer { send(.setLoading(false)) }
155+
send(.setLoading(true))
156+
var todo = try await fetchTodoByIDUseCase.execute(item.id)
157+
let now = Date()
158+
todo.isCompleted.toggle()
159+
todo.completedAt = todo.isCompleted ? now : nil
160+
todo.updatedAt = now
161+
try await upsertTodoUseCase.execute(todo)
162+
send(.didToggleCompleted(TodoListItem(from: todo)))
163+
} catch {
164+
send(.setAlert(true))
165+
}
166+
}
148167
case .togglePinned(let item):
149168
Task {
150169
do {
@@ -195,6 +214,8 @@ private extension TodoListViewModel {
195214
return effects
196215
case .tapFilterOption(let option):
197216
state.filterOption = option
217+
case .tapToggleCompleted(let todo):
218+
return [.toggleCompleted(todo)]
198219
case .tapTogglePinned(let todo):
199220
return [.togglePinned(todo)]
200221
case .undoDelete:
@@ -238,6 +259,10 @@ private extension TodoListViewModel {
238259

239260
func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] {
240261
switch action {
262+
case .didToggleCompleted(let todo):
263+
if let index = state.todos.firstIndex(where: { $0.id == todo.id }) {
264+
state.todos[index] = todo
265+
}
241266
case .didTogglePinned(let todo):
242267
if let index = state.todos.firstIndex(where: { $0.id == todo.id }) {
243268
state.todos[index] = todo

DevLog/UI/Common/Component/TodoItemRow.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ struct TodoItemRow: View {
2323
.font(.headline)
2424
.foregroundStyle(.orange)
2525
}
26+
if item.isCompleted {
27+
Image(systemName: "checkmark.circle.fill")
28+
.font(.headline)
29+
.foregroundStyle(.green)
30+
}
2631
Text(item.title)
2732
.font(.headline)
2833
.foregroundStyle(Color(.label))

0 commit comments

Comments
 (0)