swiftswiftuiswiftdata

Mark a TaskItem as complete for a certain TimeOfDay


A TaskItem can be scheduled for a certain time of day. I'd like to mark it as complete for that time of day. I have the below solution, but if I have more than one of the same TaskRow(TaskItem, Date, TimeOfDay) and I update one, the other doesn't update dynamically.

TaskRow:

import SwiftData
import SwiftUI

struct TaskRow: View {
    let task: TaskItem
    let date: Date
    let timeOfDay: TimeOfDay
    @Environment(\.modelContext) private var modelContext
    @State private var isCompleted: Bool

    init(task: TaskItem, date: Date, timeOfDay: TimeOfDay) {
        self.task = task
        self.date = date
        self.timeOfDay = timeOfDay
        _isCompleted = State(
            initialValue: task.isCompleted(for: date, timeOfDay: timeOfDay))
    }

    var body: some View {
        HStack {
            Image(systemName: isCompleted ? "checkmark.square" : "square")
                .foregroundColor(isCompleted ? .blue : .gray)
                .onTapGesture {
                    isCompleted.toggle()
                    if isCompleted {
                        let completion = TaskCompletion(
                            date: date, timeOfDay: timeOfDay, taskItem: task)
                        modelContext.insert(completion)
                    } else {
                        if let completion = task.completions.first(where: {
                            $0.date == Calendar.current.startOfDay(for: date)
                                && $0.timeOfDay == timeOfDay
                        }) {
                            modelContext.delete(completion)
                        }
                    }
                    try? modelContext.save()
                }
            Text(task.taskDescription ?? "No description available")
            Spacer()
        }
        .contentShape(Rectangle())
    }
}

TaskCompletion:

import SwiftData
import Foundation

@Model
class TaskCompletion {
    var date: Date
    var timeOfDay: TimeOfDay
    @Relationship(inverse: \TaskItem.completions) var taskItem: TaskItem

    init(date: Date, timeOfDay: TimeOfDay, taskItem: TaskItem) {
        let calendar = Calendar.current
        self.date = calendar.startOfDay(for: date)
        self.timeOfDay = timeOfDay
        self.taskItem = taskItem
    }
}

enum TimeOfDay: String, Codable, CaseIterable {
    case firstThing, morning, midDay, afternoon, evening

    var displayName: String {
        switch self {
        case .firstThing: return "First Thing in the Morning"
        case .morning: return "Morning"
        case .midDay: return "Mid Day"
        case .afternoon: return "Afternoon"
        case .evening: return "Evening"
        }
    }
}

TaskItem:

import Foundation
import SwiftData

@Model
class TaskItem {
    // Common properties
    var uuid: UUID = UUID()
    var startDate: Date
    var schedule: Set<ScheduleCombination>
    @Relationship(deleteRule: .cascade) var completions: [TaskCompletion] = []
    @Relationship(deleteRule: .cascade) var reflections: [TaskReflection] = []

    // From TaskItem
    var taskDescription: String? = nil
    @Relationship(inverse: \Blueprint.tasks) var blueprint: Blueprint?

    // From TaskSuggestion (primaryGoal is now non-optional)
    var primaryGoal: PrimaryGoal = PrimaryGoal.health // Default value
    var areaOfImprovement: AreasOfImprovement? = nil
    var easyDescription: String? = nil
    var mediumDescription: String? = nil
    var hardDescription: String? = nil
    var suggestionType: TaskSuggestionType = TaskSuggestionType.other
    var difficulty: TaskDifficulty? = nil

    var isPrimaryGoal: Bool = false
    var isAreaOfImprovement: Bool = false

    init(
        taskDescription: String? = nil,
        startDate: Date = Date(),
        schedule: Set<ScheduleCombination>,
        primaryGoal: PrimaryGoal = .health,
        blueprint: Blueprint? = nil,
        areaOfImprovement: AreasOfImprovement? = nil,
        easyDescription: String? = nil,
        mediumDescription: String? = nil,
        hardDescription: String? = nil,
        suggestionType: TaskSuggestionType = TaskSuggestionType.other,
        difficulty: TaskDifficulty? = nil,
        isPrimaryGoal: Bool = false,
        isAreaOfImprovement: Bool = false,
        uuid: UUID = UUID()
    ) {
        self.taskDescription = taskDescription
        self.startDate = startDate
        self.schedule = schedule
        self.primaryGoal = primaryGoal
        self.blueprint = blueprint
        self.areaOfImprovement = areaOfImprovement
        self.easyDescription = easyDescription
        self.mediumDescription = mediumDescription
        self.hardDescription = hardDescription
        self.suggestionType = suggestionType
        self.difficulty = difficulty
        self.isPrimaryGoal = isPrimaryGoal
        self.isAreaOfImprovement = isAreaOfImprovement
        self.uuid = uuid
    }

    // Convenience initializer to match TaskCreator calls
    convenience init(
        primaryGoal: PrimaryGoal, // Non-optional
        schedule: Set<ScheduleCombination>,
        easyDescription: String,
        mediumDescription: String,
        hardDescription: String,
        suggestionType: TaskSuggestionType,
        difficulty: TaskDifficulty
    ) {
        self.init(
            taskDescription: nil,
            startDate: Date(),
            schedule: schedule,
            primaryGoal: primaryGoal,
            blueprint: nil,
            areaOfImprovement: nil,
            easyDescription: easyDescription,
            mediumDescription: mediumDescription,
            hardDescription: hardDescription,
            suggestionType: suggestionType,
            difficulty: difficulty,
            isPrimaryGoal: suggestionType == .primaryGoal,
            isAreaOfImprovement: suggestionType == .areaOfImprovement
        )
    }

    // Scheduling and completion methods (unchanged)
    func isScheduled(for day: DayOfWeek, timeOfDay: TimeOfDay) -> Bool {
        return schedule.contains(
            ScheduleCombination(dayOfWeek: day, timeOfDay: timeOfDay))
    }

    func isCompleted(for date: Date, timeOfDay: TimeOfDay) -> Bool {
        let calendar = Calendar.current
        let startOfDay = calendar.startOfDay(for: date)
        return completions.contains {
            $0.date == startOfDay && $0.timeOfDay == timeOfDay
        }
    }

    func markCompleted(for date: Date, timeOfDay: TimeOfDay) {
        if !isCompleted(for: date, timeOfDay: timeOfDay) {
            let newCompletion = TaskCompletion(
                date: date, timeOfDay: timeOfDay, taskItem: self)
            completions.append(newCompletion)
        }
    }
}

enum DayOfWeek: String, Codable, CaseIterable {
    case monday, tuesday, wednesday, thursday, friday, saturday, sunday
}

extension Date {
    var dayOfWeek: DayOfWeek {
        let calendar = Calendar.current
        let weekday = calendar.component(.weekday, from: self)
        let index = (weekday - 2) % 7
        return DayOfWeek.allCases[index]
    }
}

struct ScheduleCombination: Hashable, Codable {
    var dayOfWeek: DayOfWeek
    var timeOfDay: TimeOfDay
}

enum TaskSuggestionType: String, Codable {
    case primaryGoal
    case areaOfImprovement
    case outcome
    case other
}

enum TaskDifficulty: String, Codable, CaseIterable, Identifiable {
    case foundational
    case intermediate
    case challenging
    
    var id: String { rawValue }
}

Solution

  • Removing the init and updating the state in .onAppear seems to have fixed the issue

    import SwiftData
    import SwiftUI
    
    struct TaskRow: View {
        let task: TaskItem
        let date: Date
        let timeOfDay: TimeOfDay
        let showCompletionStatus: Bool
        @Environment(\.modelContext) private var modelContext
        @State private var isCompletedState: Bool = false
    
        var body: some View {
                HStack {
                    Image(systemName: isCompletedState ? "checkmark.square" : "square")
                        .foregroundColor(isCompletedState ? .blue : .gray)
                        .onAppear {
                            isCompletedState = task.isCompleted(for: date, timeOfDay: timeOfDay)
                        }
                        .onTapGesture {
                            toggleCompletion()
                        }
                    Text(task.taskDescription ?? "No description available")
                    Spacer()
                    if showCompletionStatus {
                        Text(isCompletedState ? "Completed" : "Not completed")
                            .font(.subheadline)
                            .foregroundColor(isCompletedState ? .green : .red)
                            .padding(.leading, 4)
                            .onTapGesture {
                                toggleCompletion()
                            }
                    }
                }
                .contentShape(Rectangle())
        }
        
        private func toggleCompletion() {
            let startOfDay = Calendar.current.startOfDay(for: date)
            if isCompletedState {
                if let completion = task.completions.first(where: {
                    $0.date == startOfDay && $0.timeOfDay == timeOfDay
                }) {
                    modelContext.delete(completion)
                }
            } else {
                task.markCompleted(for: date, timeOfDay: timeOfDay)
            }
            try? modelContext.save()
            withAnimation(.easeInOut) {
                isCompletedState = task.isCompleted(for: date, timeOfDay: timeOfDay)
            }
        }
    }