diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index 67b7044..760b23e 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -63,6 +63,10 @@ private extension DomainAssembler { FetchTodosByKindUseCaseImpl(container.resolve(TodoRepository.self)) } + container.register(FetchTodosByDateRangeUseCase.self) { + FetchTodosByDateRangeUseCaseImpl(container.resolve(TodoRepository.self)) + } + container.register(FetchTodosByKeywordUseCase.self) { FetchTodosByKeywordUseCaseImpl(container.resolve(TodoRepository.self)) } @@ -154,5 +158,13 @@ private extension DomainAssembler { container.register(UpdatePushNotificationQueryUseCase.self) { UpdatePushNotificationQueryUseCaseImpl(container.resolve(UserPreferencesRepository.self)) } + + container.register(FetchProfileHeatmapActivityTypesUseCase.self) { + FetchProfileHeatmapActivityTypesUseCaseImpl(container.resolve(UserPreferencesRepository.self)) + } + + container.register(UpdateProfileHeatmapActivityTypesUseCase.self) { + UpdateProfileHeatmapActivityTypesUseCaseImpl(container.resolve(UserPreferencesRepository.self)) + } } } diff --git a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift index d4afa39..95bde86 100644 --- a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift +++ b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift @@ -16,6 +16,7 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { static let pushSortOrder = "PushNotification.sortOption" static let pushTimeFilter = "PushNotification.timeFilter" static let pushUnreadOnly = "PushNotification.showUnreadOnly" + static let profileHeatmapActivityTypes = "Profile.heatmap.activityTypes" } private let store: UserDefaultsStore @@ -92,4 +93,12 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { func setPushNotificationUnreadOnly(_ value: Bool) { store.setBool(value, forKey: Key.pushUnreadOnly) } + + func profileHeatmapActivityTypes() -> [String] { + store.stringArray(forKey: Key.profileHeatmapActivityTypes) + } + + func setProfileHeatmapActivityTypes(_ activityTypes: [String]) { + store.setStringArray(activityTypes, forKey: Key.profileHeatmapActivityTypes) + } } diff --git a/DevLog/Domain/Entity/TodoQuery.swift b/DevLog/Domain/Entity/TodoQuery.swift index defa482..934be89 100644 --- a/DevLog/Domain/Entity/TodoQuery.swift +++ b/DevLog/Domain/Entity/TodoQuery.swift @@ -5,21 +5,35 @@ // Created by opfic on 2/21/26. // +import Foundation + struct TodoQuery { let kind: TodoKind? let keyword: String? let isPinned: Bool? + let createdAtFrom: Date? + let createdAtTo: Date? + let createdAtDescending: Bool let pageSize: Int + let fetchAllPages: Bool init( kind: TodoKind? = nil, keyword: String? = nil, isPinned: Bool? = nil, - pageSize: Int = 20 + createdAtFrom: Date? = nil, + createdAtTo: Date? = nil, + createdAtDescending: Bool = true, + pageSize: Int = 20, + fetchAllPages: Bool = false ) { self.kind = kind self.keyword = keyword self.isPinned = isPinned + self.createdAtFrom = createdAtFrom + self.createdAtTo = createdAtTo + self.createdAtDescending = createdAtDescending self.pageSize = pageSize + self.fetchAllPages = fetchAllPages } } diff --git a/DevLog/Domain/Protocol/UserPreferencesRepository.swift b/DevLog/Domain/Protocol/UserPreferencesRepository.swift index 850593d..ba27fb4 100644 --- a/DevLog/Domain/Protocol/UserPreferencesRepository.swift +++ b/DevLog/Domain/Protocol/UserPreferencesRepository.swift @@ -27,4 +27,7 @@ protocol UserPreferencesRepository { func pushNotificationUnreadOnly() -> Bool func setPushNotificationUnreadOnly(_ value: Bool) + + func profileHeatmapActivityTypes() -> [String] + func setProfileHeatmapActivityTypes(_ activityTypes: [String]) } diff --git a/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCase.swift b/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCase.swift new file mode 100644 index 0000000..86f739f --- /dev/null +++ b/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCase.swift @@ -0,0 +1,12 @@ +// +// FetchTodosByDateRangeUseCase.swift +// DevLog +// +// Created by opfic on 3/1/26. +// + +import Foundation + +protocol FetchTodosByDateRangeUseCase { + func execute(from startDate: Date, to endDate: Date) async throws -> [Todo] +} diff --git a/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCaseImpl.swift b/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCaseImpl.swift new file mode 100644 index 0000000..76418d9 --- /dev/null +++ b/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCaseImpl.swift @@ -0,0 +1,27 @@ +// +// FetchTodosByDateRangeUseCaseImpl.swift +// DevLog +// +// Created by opfic on 3/1/26. +// + +import Foundation + +final class FetchTodosByDateRangeUseCaseImpl: FetchTodosByDateRangeUseCase { + private let repository: TodoRepository + + init(_ repository: TodoRepository) { + self.repository = repository + } + + func execute(from startDate: Date, to endDate: Date) async throws -> [Todo] { + let query = TodoQuery( + createdAtFrom: startDate, + createdAtTo: endDate, + pageSize: 100, + fetchAllPages: true + ) + let page = try await repository.fetchTodos(query, cursor: nil) + return page.items + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCase.swift new file mode 100644 index 0000000..65ee937 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchProfileHeatmapActivityTypesUseCase.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +protocol FetchProfileHeatmapActivityTypesUseCase { + func execute() -> [String] +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCaseImpl.swift new file mode 100644 index 0000000..e7df167 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchProfileHeatmapActivityTypesUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +final class FetchProfileHeatmapActivityTypesUseCaseImpl: FetchProfileHeatmapActivityTypesUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute() -> [String] { + repository.profileHeatmapActivityTypes() + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCase.swift new file mode 100644 index 0000000..4f85f28 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCase.swift @@ -0,0 +1,10 @@ +// +// UpdateProfileHeatmapActivityTypesUseCase.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +protocol UpdateProfileHeatmapActivityTypesUseCase { + func execute(_ activityTypes: [String]) +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCaseImpl.swift new file mode 100644 index 0000000..2a43f1f --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// UpdateProfileHeatmapActivityTypesUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +final class UpdateProfileHeatmapActivityTypesUseCaseImpl: UpdateProfileHeatmapActivityTypesUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute(_ activityTypes: [String]) { + repository.setProfileHeatmapActivityTypes(activityTypes) + } +} diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index 98668d9..801e617 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -20,15 +20,22 @@ final class TodoService { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } let trimmedKeyword = query.keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let logMessage = "Fetching todo page: kind=\(String(describing: query.kind)), " - + "keyword=\(trimmedKeyword), " - + "pinned=\(String(describing: query.isPinned)), " - + "cursor=\(String(describing: cursor))" - logger.info(logMessage) + let logComponents: [String?] = [ + "createdAtDescending=\(query.createdAtDescending)", + query.keyword != nil ? "keywordLength=\(trimmedKeyword.count)" : nil, + query.kind != nil ? "kind=\(query.kind!.rawValue)" : nil, + query.isPinned != nil ? "pinned=\(query.isPinned!)" : nil, + query.createdAtFrom != nil ? "createdAtFrom=\(query.createdAtFrom!)" : nil, + query.createdAtTo != nil ? "createdAtTo=\(query.createdAtTo!)" : nil, + "pageSize=\(query.pageSize)", + query.fetchAllPages ? "fetchAllPages=true" : nil, + cursor != nil ? "cursor=\(cursor!)" : nil + ] + logger.info("Fetching todo page: \(logComponents.compactMap { $0 }.joined(separator: ", "))") var firestoreQuery: Query = store .collection("users/\(uid)/todoLists/") - .order(by: "createdAt", descending: true) + .order(by: "createdAt", descending: query.createdAtDescending) .order(by: FieldPath.documentID()) if let kind = query.kind { @@ -39,7 +46,53 @@ final class TodoService { firestoreQuery = firestoreQuery.whereField("isPinned", isEqualTo: isPinned) } + if let createdAtFrom = query.createdAtFrom { + firestoreQuery = firestoreQuery.whereField( + "createdAt", + isGreaterThanOrEqualTo: Timestamp(date: createdAtFrom) + ) + } + + if let createdAtTo = query.createdAtTo { + firestoreQuery = firestoreQuery.whereField( + "createdAt", + isLessThan: Timestamp(date: createdAtTo) + ) + } + if trimmedKeyword.isEmpty { + if query.fetchAllPages { + var allItems: [TodoResponse] = [] + var pageCursor = cursor + + while true { + var pageQuery = firestoreQuery + if let pageCursor { + pageQuery = pageQuery.start(after: [ + Timestamp(date: pageCursor.createdAt), + pageCursor.documentID + ]) + } + + pageQuery = pageQuery.limit(to: query.pageSize) + let snapshot = try await pageQuery.getDocuments() + allItems.append(contentsOf: snapshot.documents.compactMap { makeResponse(from: $0) }) + + guard snapshot.documents.count == query.pageSize else { + break + } + + guard let lastDocument = snapshot.documents.last, + let nextCursor = makeCursor(from: lastDocument) else { + break + } + + pageCursor = nextCursor + } + + return TodoPageResponse(items: allItems, nextCursor: nil) + } + if let cursor { firestoreQuery = firestoreQuery.start(after: [ Timestamp(date: cursor.createdAt), @@ -47,22 +100,10 @@ final class TodoService { ]) } - let snapshot = try await firestoreQuery - .limit(to: query.pageSize) - .getDocuments() - + firestoreQuery = firestoreQuery.limit(to: query.pageSize) + let snapshot = try await firestoreQuery.getDocuments() let items = snapshot.documents.compactMap { makeResponse(from: $0) } - - let nextCursor: TodoCursorDTO? = snapshot.documents.last.flatMap { document in - guard let createdAt = document.data()[TodoFieldKey.createdAt.rawValue] as? Timestamp else { - return nil - } - - return TodoCursorDTO( - createdAt: createdAt.dateValue(), - documentID: document.documentID - ) - } + let nextCursor = snapshot.documents.last.flatMap { makeCursor(from: $0) } return TodoPageResponse(items: items, nextCursor: nextCursor) } @@ -140,6 +181,17 @@ final class TodoService { } private extension TodoService { + func makeCursor(from document: QueryDocumentSnapshot) -> TodoCursorDTO? { + guard let createdAt = document.data()[TodoFieldKey.createdAt.rawValue] as? Timestamp else { + return nil + } + + return TodoCursorDTO( + createdAt: createdAt.dateValue(), + documentID: document.documentID + ) + } + func makeResponse(from snapshot: QueryDocumentSnapshot) -> TodoResponse? { makeResponse(documentID: snapshot.documentID, data: snapshot.data()) } diff --git a/DevLog/Presentation/Structure/Profile/ProfileActivityType.swift b/DevLog/Presentation/Structure/Profile/ProfileActivityType.swift new file mode 100644 index 0000000..f2ef586 --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileActivityType.swift @@ -0,0 +1,20 @@ +// +// ProfileActivityType.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +enum ProfileActivityType: String, CaseIterable, Hashable { + case created + case completed + + var title: String { + switch self { + case .created: return "생성" + case .completed: return "완료" + } + } +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift b/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift new file mode 100644 index 0000000..ee6bd8f --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift @@ -0,0 +1,15 @@ +// +// ProfileCompletionDay.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +struct ProfileCompletionDay: Hashable { + let date: Date + let createdCount: Int + let completedCount: Int + let isInMonth: Bool +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionMonth.swift b/DevLog/Presentation/Structure/Profile/ProfileCompletionMonth.swift new file mode 100644 index 0000000..a567540 --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileCompletionMonth.swift @@ -0,0 +1,14 @@ +// +// ProfileCompletionMonth.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +struct ProfileCompletionMonth: Identifiable, Hashable { + var id: Date { monthStart } + let monthStart: Date + let weeks: [[ProfileCompletionDay]] +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift b/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift new file mode 100644 index 0000000..c8590c4 --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift @@ -0,0 +1,15 @@ +// +// ProfileCompletionQuarter.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +struct ProfileCompletionQuarter: Identifiable, Hashable { + let quarterStart: Date + let months: [ProfileCompletionMonth] + + var id: Date { quarterStart } +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift b/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift new file mode 100644 index 0000000..fb56c40 --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift @@ -0,0 +1,23 @@ +// +// ProfileSelectedDayActivity.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +struct ProfileSelectedDayActivity: Identifiable, Hashable { + let todo: Todo + let showsCreated: Bool + let showsCompleted: Bool + + var id: String { todo.id } + + var activityLabel: String { + if showsCreated && showsCompleted { + return "생성/완료" + } + return showsCreated ? "생성" : "완료" + } +} diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 1da72e6..e551d87 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -13,13 +13,16 @@ final class ProfileViewModel: Store { var email: String = "" var statusMessage: String = "" var avatarURL: URL? + var selectedQuarterStart: Date? + var completionQuarter: ProfileCompletionQuarter? + var dayActivitiesByDate: [Date: [ProfileSelectedDayActivity]] = [:] + var selectedActivityTypes: Set = [.created, .completed] + var selectedDay: ProfileCompletionDay? + var selectedActivityForSheet: ProfileSelectedDayActivity? var showDoneButton: Bool = false var showAlert: Bool = false var alertTitle: String = "" var alertMessage: String = "" - var resetButtonEnabled: Bool { - !statusMessage.isEmpty && showDoneButton - } } enum Action { @@ -28,33 +31,102 @@ final class ProfileViewModel: Store { case tapResetStatusMessageButton case willUpdateStatusMessage case fetchUserData(UserProfile) + case setCompletionQuarter( + quarterStart: Date, + quarter: ProfileCompletionQuarter, + dayActivitiesByDate: [Date: [ProfileSelectedDayActivity]] + ) + case moveQuarter(Int) + case toggleActivityType(ProfileActivityType) + case selectDay(ProfileCompletionDay?) + case setSelectedActivityForSheet(ProfileSelectedDayActivity?) case updateStatusMessage(String) case updateStatusTextFieldFocus(Bool) } enum SideEffect { case fetchUserData + case fetchCompletionQuarter(Date) case updateStatusMessage(String) + case updateHeatmapActivityTypes(Set) } + @Published private(set) var state = State() private let fetchUserDataUseCase: FetchUserDataUseCase + private let fetchTodosByDateRangeUseCase: FetchTodosByDateRangeUseCase private let upsertStatusMessageUseCase: UpsertStatusMessageUseCase - @Published private(set) var state = State() + private let fetchHeatmapActivityTypesUseCase: FetchProfileHeatmapActivityTypesUseCase + private let updateHeatmapActivityTypesUseCase: UpdateProfileHeatmapActivityTypesUseCase + private let calendar = Calendar.current + + var quarterTitle: String { + guard let start = state.selectedQuarterStart else { return "" } + let year = calendar.component(.year, from: start) + let month = calendar.component(.month, from: start) + let quarter = ((month - 1) / 3) + 1 + return "\(year) Q\(quarter)" + } + + var resetButtonEnabled: Bool { + !state.statusMessage.isEmpty && state.showDoneButton + } + + var selectedQuarter: ProfileCompletionQuarter? { + state.completionQuarter + } + + var selectedDayActivities: [ProfileSelectedDayActivity] { + guard let selectedDay = state.selectedDay else { return [] } + let dayStart = calendar.startOfDay(for: selectedDay.date) + let activities = state.dayActivitiesByDate[dayStart] ?? [] + + return activities.filter { activity in + (state.selectedActivityTypes.contains(.created) && activity.showsCreated) + || (state.selectedActivityTypes.contains(.completed) && activity.showsCompleted) + } + } + + var canMoveToPreviousQuarter: Bool { + canMoveToQuarter(offsetMonths: -3) + } + + var canMoveToNextQuarter: Bool { + canMoveToQuarter(offsetMonths: 3) + } init( fetchUserDataUseCase: FetchUserDataUseCase, - upsertStatusMessageUseCase: UpsertStatusMessageUseCase + fetchTodosByDateRangeUseCase: FetchTodosByDateRangeUseCase, + upsertStatusMessageUseCase: UpsertStatusMessageUseCase, + fetchHeatmapActivityTypesUseCase: FetchProfileHeatmapActivityTypesUseCase, + updateHeatmapActivityTypesUseCase: UpdateProfileHeatmapActivityTypesUseCase ) { self.fetchUserDataUseCase = fetchUserDataUseCase + self.fetchTodosByDateRangeUseCase = fetchTodosByDateRangeUseCase self.upsertStatusMessageUseCase = upsertStatusMessageUseCase + self.fetchHeatmapActivityTypesUseCase = fetchHeatmapActivityTypesUseCase + self.updateHeatmapActivityTypesUseCase = updateHeatmapActivityTypesUseCase } + // swiftlint:disable cyclomatic_complexity func reduce(with action: Action) -> [SideEffect] { var state = self.state var effects: [SideEffect] = [] switch action { case .onAppear: + if state.selectedQuarterStart == nil { + guard let quarterStart = quarterStart(for: Date(), calendar: calendar) else { break } + state.selectedQuarterStart = quarterStart + } + let rawValues = fetchHeatmapActivityTypesUseCase.execute() + let settings = normalizeActivityTypes(rawValues) + if !settings.isEmpty { + state.selectedActivityTypes = settings + } effects = [.fetchUserData] + if let selectedQuarterStart = state.selectedQuarterStart { + effects.append(.fetchCompletionQuarter(selectedQuarterStart)) + } case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) case .tapResetStatusMessageButton: @@ -64,6 +136,46 @@ final class ProfileViewModel: Store { state.email = profile.email state.statusMessage = profile.statusMessage state.avatarURL = profile.avatarURL + case .setCompletionQuarter(let quarterStart, let quarter, let dayActivitiesByDate): + guard state.selectedQuarterStart == quarterStart else { break } + state.completionQuarter = quarter + state.dayActivitiesByDate = dayActivitiesByDate + case .selectDay(let day): + if let day, state.selectedDay?.date == day.date { + state.selectedDay = nil + } else { + state.selectedDay = day + } + case .setSelectedActivityForSheet(let activity): + state.selectedActivityForSheet = activity + case .moveQuarter(let delta): + guard let selectedQuarterStart = state.selectedQuarterStart else { break } + let monthDelta = 3 * delta + guard let nextQuarterStart = calendar.date( + byAdding: .month, + value: monthDelta, + to: selectedQuarterStart + ) else { break } + let today = calendar.startOfDay(for: Date()) + guard canMove(to: nextQuarterStart, calendar: calendar, today: today) else { break } + + state.selectedQuarterStart = nextQuarterStart + state.completionQuarter = nil + state.dayActivitiesByDate = [:] + state.selectedDay = nil + state.selectedActivityForSheet = nil + effects = [.fetchCompletionQuarter(nextQuarterStart)] + case .toggleActivityType(let activityType): + if state.selectedActivityTypes.contains(activityType), state.selectedActivityTypes.count == 1 { + break + } + + if state.selectedActivityTypes.contains(activityType) { + state.selectedActivityTypes.remove(activityType) + } else { + state.selectedActivityTypes.insert(activityType) + } + effects = [.updateHeatmapActivityTypes(state.selectedActivityTypes)] case .willUpdateStatusMessage: let message = self.state.statusMessage effects = [.updateStatusMessage(message)] @@ -75,6 +187,7 @@ final class ProfileViewModel: Store { self.state = state return effects } + // swiftlint:enable cyclomatic_complexity func run(_ effect: SideEffect) { switch effect { @@ -87,6 +200,24 @@ final class ProfileViewModel: Store { send(.setAlert(true)) } } + case .fetchCompletionQuarter(let quarterStart): + Task { + do { + let todos = try await fetchQuarterTodos(from: quarterStart) + let months = makeCompletionMonths(from: todos, quarterStart: quarterStart) + let quarter = ProfileCompletionQuarter(quarterStart: quarterStart, months: months) + let dayActivitiesByDate = makeDayActivitiesByDate(from: todos) + send( + .setCompletionQuarter( + quarterStart: quarterStart, + quarter: quarter, + dayActivitiesByDate: dayActivitiesByDate + ) + ) + } catch { + send(.setAlert(true)) + } + } case .updateStatusMessage(let message): Task { do { @@ -95,11 +226,45 @@ final class ProfileViewModel: Store { send(.setAlert(true)) } } + case .updateHeatmapActivityTypes(let activityTypes): + let rawValues = ProfileActivityType.allCases + .filter { activityTypes.contains($0) } + .map(\.rawValue) + updateHeatmapActivityTypesUseCase.execute(rawValues) } } } private extension ProfileViewModel { + func makeDayActivitiesByDate(from todos: [Todo]) -> [Date: [ProfileSelectedDayActivity]] { + var activitiesByDate: [Date: [ProfileSelectedDayActivity]] = [:] + + for todo in todos { + let createdDay = calendar.startOfDay(for: todo.createdAt) + let completedDay = todo.isCompleted ? calendar.startOfDay(for: todo.updatedAt) : nil + + activitiesByDate[createdDay, default: []].append( + ProfileSelectedDayActivity( + todo: todo, + showsCreated: true, + showsCompleted: completedDay == createdDay + ) + ) + + if let completedDay, completedDay != createdDay { + activitiesByDate[completedDay, default: []].append( + ProfileSelectedDayActivity( + todo: todo, + showsCreated: false, + showsCompleted: true + ) + ) + } + } + + return activitiesByDate + } + func setAlert( _ state: inout State, isPresented: Bool @@ -108,4 +273,118 @@ private extension ProfileViewModel { state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." state.showAlert = isPresented } + + func fetchQuarterTodos(from quarterStart: Date) async throws -> [Todo] { + guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { + return [] + } + + return try await fetchTodosByDateRangeUseCase.execute( + from: quarterStart, + to: nextQuarterStart + ) + } + + func canMove(to quarterStart: Date, calendar: Calendar, today: Date) -> Bool { + guard let quarterEnd = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { + return false + } + let interval = DateInterval(start: quarterStart, end: quarterEnd) + return interval.contains(today) || quarterEnd <= today + } + + func normalizeActivityTypes(_ rawValues: [String]) -> Set { + Set(rawValues.compactMap(ProfileActivityType.init(rawValue:))) + } + + func makeCompletionMonths(from todos: [Todo], quarterStart: Date) -> [ProfileCompletionMonth] { + var dailyCreatedCount: [Date: Int] = [:] + var dailyCompletedCount: [Date: Int] = [:] + + for todo in todos { + let createdDay = calendar.startOfDay(for: todo.createdAt) + dailyCreatedCount[createdDay, default: 0] += 1 + + if todo.isCompleted { + let completedDay = calendar.startOfDay(for: todo.updatedAt) + dailyCompletedCount[completedDay, default: 0] += 1 + } + } + + let monthStarts = (0..<3).compactMap { + calendar.date(byAdding: .month, value: $0, to: quarterStart) + } + + return monthStarts.map { monthStart in + makeCompletionMonth( + monthStart: monthStart, + createdCounts: dailyCreatedCount, + completedCounts: dailyCompletedCount, + calendar: calendar + ) + } + } + + func makeCompletionMonth( + monthStart: Date, + createdCounts: [Date: Int], + completedCounts: [Date: Int], + calendar: Calendar + ) -> ProfileCompletionMonth { + guard let monthInterval = calendar.dateInterval(of: .month, for: monthStart), + let monthLastDay = calendar.date(byAdding: .day, value: -1, to: monthInterval.end), + let firstWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthInterval.start), + let lastWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthLastDay) else { + return ProfileCompletionMonth(monthStart: monthStart, weeks: []) + } + + var days: [ProfileCompletionDay] = [] + var cursor = firstWeekInterval.start + while cursor < lastWeekInterval.end { + let normalizedDate = calendar.startOfDay(for: cursor) + let isInMonth = calendar.isDate(normalizedDate, equalTo: monthStart, toGranularity: .month) + let createdCount = isInMonth ? (createdCounts[normalizedDate] ?? 0) : 0 + let completedCount = isInMonth ? (completedCounts[normalizedDate] ?? 0) : 0 + days.append( + ProfileCompletionDay( + date: normalizedDate, + createdCount: createdCount, + completedCount: completedCount, + isInMonth: isInMonth + ) + ) + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = nextDay + } + + var weeks: [[ProfileCompletionDay]] = [] + var index = 0 + while index < days.count { + let endIndex = min(index + 7, days.count) + weeks.append(Array(days[index.. Date? { + let month = calendar.component(.month, from: date) + let startMonth = ((month - 1) / 3) * 3 + 1 + var components = calendar.dateComponents([.year], from: date) + components.month = startMonth + components.day = 1 + return calendar.date(from: components) + } + + func canMoveToQuarter(offsetMonths: Int) -> Bool { + guard let selectedQuarterStart = state.selectedQuarterStart else { return false } + guard let targetQuarterStart = calendar.date( + byAdding: .month, value: offsetMonths, to: selectedQuarterStart) + else { + return false + } + let today = calendar.startOfDay(for: Date()) + return canMove(to: targetQuarterStart, calendar: calendar, today: today) + } } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 029c3b1..7e89b91 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -310,6 +310,9 @@ }, "베타 테스트 참여" : { + }, + "분기별 활동 히트맵" : { + }, "사용자 설정" : { @@ -439,6 +442,9 @@ }, "확인" : { + }, + "활동 없음" : { + }, "회원 탈퇴" : { diff --git a/DevLog/UI/Common/Componeent/CacheableImage.swift b/DevLog/UI/Common/Component/CacheableImage.swift similarity index 100% rename from DevLog/UI/Common/Componeent/CacheableImage.swift rename to DevLog/UI/Common/Component/CacheableImage.swift diff --git a/DevLog/UI/Common/Componeent/CheckBox.swift b/DevLog/UI/Common/Component/CheckBox.swift similarity index 100% rename from DevLog/UI/Common/Componeent/CheckBox.swift rename to DevLog/UI/Common/Component/CheckBox.swift diff --git a/DevLog/UI/Common/Componeent/LoadingView.swift b/DevLog/UI/Common/Component/LoadingView.swift similarity index 100% rename from DevLog/UI/Common/Componeent/LoadingView.swift rename to DevLog/UI/Common/Component/LoadingView.swift diff --git a/DevLog/UI/Common/Componeent/LoginButton.swift b/DevLog/UI/Common/Component/LoginButton.swift similarity index 100% rename from DevLog/UI/Common/Componeent/LoginButton.swift rename to DevLog/UI/Common/Component/LoginButton.swift diff --git a/DevLog/UI/Common/Componeent/Tag+.swift b/DevLog/UI/Common/Component/Tag+.swift similarity index 100% rename from DevLog/UI/Common/Componeent/Tag+.swift rename to DevLog/UI/Common/Component/Tag+.swift diff --git a/DevLog/UI/Common/Componeent/Toast.swift b/DevLog/UI/Common/Component/Toast.swift similarity index 100% rename from DevLog/UI/Common/Componeent/Toast.swift rename to DevLog/UI/Common/Component/Toast.swift diff --git a/DevLog/UI/Common/Componeent/TodoItemRow.swift b/DevLog/UI/Common/Component/TodoItemRow.swift similarity index 100% rename from DevLog/UI/Common/Componeent/TodoItemRow.swift rename to DevLog/UI/Common/Component/TodoItemRow.swift diff --git a/DevLog/UI/Common/Componeent/ToolbarButton+.swift b/DevLog/UI/Common/Component/ToolbarButton+.swift similarity index 100% rename from DevLog/UI/Common/Componeent/ToolbarButton+.swift rename to DevLog/UI/Common/Component/ToolbarButton+.swift diff --git a/DevLog/UI/Common/Componeent/WebItemRow.swift b/DevLog/UI/Common/Component/WebItemRow.swift similarity index 100% rename from DevLog/UI/Common/Componeent/WebItemRow.swift rename to DevLog/UI/Common/Component/WebItemRow.swift diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 3733932..049cd3d 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -36,7 +36,10 @@ struct MainView: View { } ProfileView(viewModel: ProfileViewModel( fetchUserDataUseCase: container.resolve(FetchUserDataUseCase.self), - upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self) + fetchTodosByDateRangeUseCase: container.resolve(FetchTodosByDateRangeUseCase.self), + upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self), + fetchHeatmapActivityTypesUseCase: container.resolve(FetchProfileHeatmapActivityTypesUseCase.self), + updateHeatmapActivityTypesUseCase: container.resolve(UpdateProfileHeatmapActivityTypesUseCase.self) )) .tabItem { Image(systemName: "person.crop.circle.fill") diff --git a/DevLog/UI/Common/TodoDetailContentView.swift b/DevLog/UI/Common/TodoDetailContentView.swift new file mode 100644 index 0000000..04ac5a4 --- /dev/null +++ b/DevLog/UI/Common/TodoDetailContentView.swift @@ -0,0 +1,46 @@ +// +// TodoDetailContentView.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import SwiftUI +import MarkdownUI + +struct TodoDetailContentView: View { + let title: String + let content: String + var activityLabel: String? + + var body: some View { + ZStack { + Color(.secondarySystemBackground).ignoresSafeArea() + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + if let activityLabel { + HStack { + Text(activityLabel) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color(.systemGray4)) + ) + Spacer() + } + .padding(.horizontal) + } + Text(title) + .font(.title3.bold()) + .padding(.horizontal) + Divider() + Markdown(content) + .padding(.horizontal) + } + } + } + } +} diff --git a/DevLog/UI/Common/TodoInfoSheetView.swift b/DevLog/UI/Common/TodoInfoSheetView.swift new file mode 100644 index 0000000..1cecba8 --- /dev/null +++ b/DevLog/UI/Common/TodoInfoSheetView.swift @@ -0,0 +1,71 @@ +// +// TodoInfoSheetView.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +import SwiftUI + +struct TodoInfoSheetView: View { + let dueDate: Date? + let tags: [String] + let onClose: () -> Void + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 32) { + VStack { + HStack { + Text("마감일") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } + HStack(spacing: 8) { + Image(systemName: "calendar") + .foregroundStyle(.secondary) + Text( + dueDate? + .formatted(date: .abbreviated, time: .omitted) + ?? "마감일 없음" + ) + .foregroundStyle(dueDate == nil ? .secondary : .primary) + Spacer() + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.tertiarySystemFill)) + ) + Divider() + } + VStack { + HStack { + Text("태그") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } + Divider() + if !tags.isEmpty { + TagLayout { + ForEach(tags, id: \.self) { tag in + Tag(tag, isEditing: false) + } + } + } + } + } + .padding(.horizontal) + } + .toolbar { + ToolbarLeadingButton { + onClose() + } + } + } + } +} diff --git a/DevLog/UI/Home/TodoDetailView.swift b/DevLog/UI/Home/TodoDetailView.swift index 52faa11..12e5af9 100644 --- a/DevLog/UI/Home/TodoDetailView.swift +++ b/DevLog/UI/Home/TodoDetailView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import MarkdownUI struct TodoDetailView: View { @StateObject var viewModel: TodoDetailViewModel @@ -15,16 +14,10 @@ struct TodoDetailView: View { ZStack { Color(.secondarySystemBackground).ignoresSafeArea() if let todo = viewModel.state.todo { - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - Text(todo.title) - .font(.title3.bold()) - .padding(.horizontal) - Divider() - Markdown(todo.content) - .padding(.horizontal) - } - } + TodoDetailContentView( + title: todo.title, + content: todo.content + ) } else if viewModel.state.isLoading { LoadingView() } @@ -72,61 +65,11 @@ struct TodoDetailView: View { } private var sheetContent: some View { - NavigationStack { - ScrollView { - LazyVStack(spacing: 32) { - VStack { - HStack { - Text("마감일") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } - HStack(spacing: 8) { - Image(systemName: "calendar") - .foregroundStyle(.secondary) - Text( - viewModel.state.todo?.dueDate? - .formatted(date: .abbreviated, time: .omitted) - ?? "마감일 없음" - ) - .foregroundStyle( - viewModel.state.todo?.dueDate == nil ? .secondary : .primary - ) - Spacer() - } - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.tertiarySystemFill)) - ) - Divider() - } - VStack { - HStack { - Text("태그") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } - Divider() - if let tags = viewModel.state.todo?.tags, !tags.isEmpty { - TagLayout { - ForEach(tags, id: \.self) { tag in - Tag(tag, isEditing: false) - } - } - } - } - } - .padding(.horizontal) - } - .toolbar { - ToolbarLeadingButton { - viewModel.send(.setShowInfo(false)) - } - } + TodoInfoSheetView( + dueDate: viewModel.state.todo?.dueDate, + tags: viewModel.state.todo?.tags ?? [] + ) { + viewModel.send(.setShowInfo(false)) } } } diff --git a/DevLog/UI/Profile/ProfileHeatmapView.swift b/DevLog/UI/Profile/ProfileHeatmapView.swift new file mode 100644 index 0000000..83aa92a --- /dev/null +++ b/DevLog/UI/Profile/ProfileHeatmapView.swift @@ -0,0 +1,160 @@ +// +// ProfileHeatmapView.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +import SwiftUI + +struct ProfileHeatmapView: View { + let quarter: ProfileCompletionQuarter + let selectedActivityTypes: Set + let selectedDay: ProfileCompletionDay? + let onSelectDay: (ProfileCompletionDay) -> Void + + var body: some View { + HStack(alignment: .top, spacing: 0) { + weekdayLabel + .padding(.trailing, 10) + let months = quarter.months + ForEach(Array(zip(months.indices, months)), id: \.1) { index, month in + MonthCompactHeatmapView( + month: month, + selectedActivityTypes: selectedActivityTypes, + selectedDay: selectedDay, + onSelectDay: onSelectDay + ) + if index < months.count - 1 { + Spacer() + } + } + } + } + + @ViewBuilder + private var weekdayLabel: some View { + let labels: [Int: String] = [ + 2: "월", + 4: "수", + 6: "금" + ] + let orderedWeekdays = Array(1...7) + let cellSize: CGFloat = 16 + + VStack(alignment: .leading, spacing: 4) { + ForEach(orderedWeekdays, id: \.self) { weekday in + Group { + if let label = labels[weekday] { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: cellSize, height: cellSize) + } else { + Color.clear + .frame(width: cellSize, height: cellSize) + } + } + } + } + .padding(.top, 22) + } +} + +private struct MonthCompactHeatmapView: View { + @Environment(\.colorScheme) private var colorScheme + let month: ProfileCompletionMonth + let selectedActivityTypes: Set + let selectedDay: ProfileCompletionDay? + let onSelectDay: (ProfileCompletionDay) -> Void + private let orderedWeekdays = Array(1...7) + private let cellSize: CGFloat = 16 + 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) + .font(.caption) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: cellSpacing) { + ForEach(orderedWeekdays, id: \.self) { weekday in + HStack(spacing: cellSpacing) { + ForEach(month.weeks.indices, id: \.self) { weekIndex in + let day = month.weeks[weekIndex].first { + Calendar.current.component(.weekday, from: $0.date) == weekday + } + + RoundedRectangle(cornerRadius: 3) + .fill(fillColor(for: day, with: maxCount)) + .overlay( + RoundedRectangle(cornerRadius: 3) + .stroke(selectionInnerBorderColor(for: day), lineWidth: 2) + ) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(selectionOuterBorderColor(for: day), lineWidth: 0.8) + .padding(-1) + ) + .frame(width: cellSize, height: cellSize) + .onTapGesture { + if let day, day.isInMonth { + onSelectDay(day) + } + } + } + } + } + } + } + } + + private func isSelected(_ day: ProfileCompletionDay?) -> Bool { + guard let day, let selectedDay else { return false } + return Calendar.current.isDate(day.date, inSameDayAs: selectedDay.date) + } + + private func selectionInnerBorderColor(for day: ProfileCompletionDay?) -> Color { + isSelected(day) ? .white : .clear + } + + private func selectionOuterBorderColor(for day: ProfileCompletionDay?) -> Color { + if isSelected(day) && colorScheme == .light { + return Color.gray + } + return .clear + } + + private func fillColor(for day: ProfileCompletionDay?, with maxCount: Int) -> Color { + guard let day, day.isInMonth else { return .clear } + let count = dayCount(for: day) + if count == 0 { + return Color(.systemGray5) + } + return Color.blue.opacity(opacity(for: count, max: maxCount)) + } + + private func dayCount(for day: ProfileCompletionDay) -> Int { + var value = 0 + if selectedActivityTypes.contains(.created) { + value += day.createdCount + } + if selectedActivityTypes.contains(.completed) { + value += day.completedCount + } + return value + } + + private func opacity(for count: Int, max: Int) -> Double { + guard 0 < count && 0 < max else { return 0 } + let ratio = Double(count) / Double(max) + return ceil(ratio * 10) / 10 + } +} diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 7f6459e..d758801 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -12,12 +12,11 @@ struct ProfileView: View { @StateObject private var router = NavigationRouter() @Environment(\.diContainer) private var container @FocusState private var focusedOnStatusMessageTextField: Bool - @State private var showDoneBtn: Bool = false var body: some View { NavigationStack(path: $router.path) { ScrollView { - VStack(alignment: .leading, spacing: 16) { + LazyVStack(alignment: .leading, spacing: 16) { HStack { CacheableImage(url: viewModel.state.avatarURL) .frame(width: 60, height: 60) @@ -46,7 +45,7 @@ struct ProfileView: View { } .focused($focusedOnStatusMessageTextField) - if viewModel.state.resetButtonEnabled { + if viewModel.resetButtonEnabled { Button(action: { viewModel.send(.tapResetStatusMessageButton) }) { @@ -71,36 +70,37 @@ struct ProfileView: View { .transition(.move(edge: .trailing).combined(with: .opacity)) } } + activityHeatmapSection } - .padding(.horizontal) + .padding(.horizontal, 16) } .frame(maxWidth: .infinity) - .background(Color(UIColor.systemGroupedBackground)) + .background(Color(.systemGroupedBackground)) .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 0) { Button { router.push(Path.settings) } label: { Image(systemName: "gearshape") } - Button(action: { - // TODO: 기능 추가 생각해야함 - }) { - Image(systemName: "plus") - } } } } - .navigationDestination(for: Path.self) { _ in - SettingView(viewModel: SettingViewModel( - deleteAuthUseCase: container.resolve(DeleteAuthUseCase.self), - signOutUseCase: container.resolve(SignOutUseCase.self), - sessionUseCase: container.resolve(AuthSessionUseCase.self), - observeSystemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), - updateSystemThemeUseCase: container.resolve(UpdateSystemThemeUseCase.self) - )) - .environmentObject(router) + .navigationDestination(for: Path.self) { path in + switch path { + case .settings: + SettingView(viewModel: SettingViewModel( + deleteAuthUseCase: container.resolve(DeleteAuthUseCase.self), + signOutUseCase: container.resolve(SignOutUseCase.self), + sessionUseCase: container.resolve(AuthSessionUseCase.self), + observeSystemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), + updateSystemThemeUseCase: container.resolve(UpdateSystemThemeUseCase.self) + )) + .environmentObject(router) + case .activity(let activity): + ProfileActivityTodoDetailView(activity: activity) + } } .onAppear { viewModel.send(.onAppear) @@ -122,7 +122,182 @@ struct ProfileView: View { } } + private var activityHeatmapSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("분기별 활동 히트맵") + .font(.headline) + Spacer() + activityTypeSelector + } + + quarterNavigator + + if viewModel.selectedQuarter == nil { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 140) + } else if let quarter = viewModel.selectedQuarter { + ProfileHeatmapView( + quarter: quarter, + selectedActivityTypes: viewModel.state.selectedActivityTypes, + selectedDay: viewModel.state.selectedDay, + onSelectDay: { day in + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.send(.selectDay(day)) + } + } + ) + .padding(.vertical, 6) + + if let selectedDay = viewModel.state.selectedDay { + selectedDayDetailSection(for: selectedDay) + .transition(.opacity) + } + } else { + EmptyView() + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color(.secondarySystemGroupedBackground)) + ) + } + + private var activityTypeSelector: some View { + Menu { + ForEach(ProfileActivityType.allCases, id: \.self) { activityType in + Button { + viewModel.send(.toggleActivityType(activityType)) + } label: { + HStack { + Text(activityType.title) + if viewModel.state.selectedActivityTypes.contains(activityType) { + Image(systemName: "checkmark") + .tint(.blue) + } + } + } + } + } label: { + Text("편집") + .foregroundStyle(.blue) + } + } + + private var quarterNavigator: some View { + HStack { + Button { + viewModel.send(.moveQuarter(-1)) + } label: { + Image(systemName: "chevron.left") + } + .disabled(!viewModel.canMoveToPreviousQuarter) + + Spacer() + + Text(viewModel.quarterTitle) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + + Button { + viewModel.send(.moveQuarter(1)) + } label: { + Image(systemName: "chevron.right") + } + .disabled(!viewModel.canMoveToNextQuarter) + } + } + + @ViewBuilder + private func selectedDayDetailSection(for day: ProfileCompletionDay) -> some View { + let activities = viewModel.selectedDayActivities + + VStack(alignment: .leading, spacing: 12) { + Text(day.date.formatted(.dateTime.year().month(.wide).day())) + .font(.subheadline) + .bold() + + if activities.isEmpty { + Text("활동 없음") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) + } else { + ForEach(activities) { activity in + Button { + router.push(Path.activity(activity)) + } label: { + HStack(spacing: 8) { + Image(systemName: activity.todo.kind.symbolName) + .foregroundStyle(activity.todo.kind.color) + .frame(width: 20) + Text(activity.todo.title) + .font(.caption) + .lineLimit(1) + Text(activity.activityLabel) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color(.systemGray4)) + ) + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + .padding(.vertical, 2) + } + } + } + .padding(.top, 4) + } + private enum Path: Hashable { case settings + case activity(ProfileSelectedDayActivity) + } +} + +private struct ProfileActivityTodoDetailView: View { + let activity: ProfileSelectedDayActivity + @State private var showInfo: Bool = false + + var body: some View { + TodoDetailContentView( + title: activity.todo.title, + content: activity.todo.content, + activityLabel: activity.activityLabel + ) + .sheet(isPresented: $showInfo) { + infoSheetContent + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showInfo = true + } label: { + Image(systemName: "info.circle") + } + } + } + } + + private var infoSheetContent: some View { + TodoInfoSheetView( + dueDate: activity.todo.dueDate, + tags: activity.todo.tags + ) { + showInfo = false + } } }