From 086a2be1f701d08990dfad51ad348afd88eb4f86 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Fri, 26 Sep 2025 15:16:17 +0200 Subject: [PATCH 01/49] Introduce temporary feature flag. --- .../CommonModels/AppEnvironment/ExperimentalFeature.swift | 1 + Student/Student/StudentTabBarController.swift | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Core/Core/Common/CommonModels/AppEnvironment/ExperimentalFeature.swift b/Core/Core/Common/CommonModels/AppEnvironment/ExperimentalFeature.swift index 3a3f2be94d..b1af3d8a22 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/ExperimentalFeature.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/ExperimentalFeature.swift @@ -29,6 +29,7 @@ public enum ExperimentalFeature: String, CaseIterable, Codable { case K5Dashboard = "enable_K5_dashboard" case whatIfScore = "what_if_score" case rebuiltCalendar = "rebuilt_calendar" + case newStudentToDoScreen = "new_student_todo_screen" public var isEnabled: Bool { get { diff --git a/Student/Student/StudentTabBarController.swift b/Student/Student/StudentTabBarController.swift index 030b087b52..5671e501f3 100644 --- a/Student/Student/StudentTabBarController.swift +++ b/Student/Student/StudentTabBarController.swift @@ -142,7 +142,10 @@ class StudentTabBarController: UITabBarController, SnackBarProvider { func todoTab() -> UIViewController { let todo = CoreSplitViewController() - let todoController = TodoListViewController.create() + let todoController = { + let useNewTodo = ExperimentalFeature.newStudentToDoScreen.isEnabled + return useNewTodo ? TodoAssembly.makeTodoListViewController(env: .shared) : TodoListViewController.create() + }() todo.viewControllers = [ CoreNavigationController(rootViewController: todoController), CoreNavigationController(rootViewController: EmptyViewController()) From 92a4f8377c3a17f42473b3b644f9bd2c5579d781 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Fri, 26 Sep 2025 15:17:10 +0200 Subject: [PATCH 02/49] Remove isEmpty state publishing. --- .../Features/Todos/Model/TodoInteractor.swift | 15 +++++----- .../Todos/ViewModel/TodoListViewModel.swift | 7 +++-- .../Todos/Model/TodoInteractorLiveTests.swift | 26 +++++----------- .../Todos/Model/TodoInteractorMock.swift | 8 ++--- .../ViewModel/TodoListViewModelTests.swift | 30 +++++++++++-------- 5 files changed, 39 insertions(+), 47 deletions(-) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index 263b646d9b..2156f9ee9c 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -20,9 +20,8 @@ import Foundation import Combine public protocol TodoInteractor { - typealias IsEmptyState = Bool var todos: AnyPublisher<[TodoItem], Never> { get } - func refresh(ignoreCache: Bool) -> AnyPublisher + func refresh(ignoreCache: Bool) -> AnyPublisher } public final class TodoInteractorLive: TodoInteractor { @@ -43,7 +42,7 @@ public final class TodoInteractorLive: TodoInteractor { self.env = env } - public func refresh(ignoreCache: Bool) -> AnyPublisher { + public func refresh(ignoreCache: Bool) -> AnyPublisher { ReactiveStore(useCase: GetCourses()) .getEntities(ignoreCache: ignoreCache) .map { @@ -58,9 +57,9 @@ public final class TodoInteractorLive: TodoInteractor { .getEntities(ignoreCache: ignoreCache, loadAllPages: true) .map { $0.compactMap(TodoItem.init) } } - .map { [weak self] in - self?.todosSubject.value = $0 - return $0.isEmpty + .map { [weak self] (todos: [TodoItem]) in + self?.todosSubject.value = todos + return () } .eraseToAnyPublisher() } @@ -76,8 +75,8 @@ public final class TodoInteractorPreview: TodoInteractor { self.todos = Publishers.typedJust(todos) } - public func refresh(ignoreCache: Bool) -> AnyPublisher { - Publishers.typedJust(false) + public func refresh(ignoreCache: Bool) -> AnyPublisher { + Publishers.typedJust(()) } } diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index 29a7c69062..8ad0eb73bd 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -32,7 +32,7 @@ public class TodoListViewModel: ObservableObject { self.interactor = interactor self.env = env - self.interactor.todos + interactor.todos .assign(to: \.items, on: self, ownership: .weak) .store(in: &subscriptions) @@ -44,8 +44,9 @@ public class TodoListViewModel: ObservableObject { .sinkFailureOrValue { [weak self] _ in self?.state = .error completion() - } receiveValue: { [weak self] isEmpty in - self?.state = isEmpty ? .empty : .data + } receiveValue: { [weak self] _ in + let isListEmpty = self?.items.isEmpty == true + self?.state = isListEmpty ? .empty : .data completion() } .store(in: &subscriptions) diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift index 30d1cfaddc..17b9dfc806 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift @@ -63,9 +63,7 @@ class TodoInteractorLiveTests: CoreTestCase { mockPlannables(plannables, contextCodes: makeContextCodes(courseIds: ["1", "2"])) // Then - XCTAssertFirstValueAndCompletion(testee.refresh(ignoreCache: false)) { isEmpty in - XCTAssertFalse(isEmpty) - } + XCTAssertFinish(testee.refresh(ignoreCache: false)) XCTAssertFirstValue(testee.todos) { todos in XCTAssertEqual(todos.count, 2) XCTAssertEqual(todos[0].title, "Assignment 1") @@ -79,7 +77,7 @@ class TodoInteractorLiveTests: CoreTestCase { mockPlannables([], contextCodes: makeUserContextCodes()) // Then - XCTAssertCompletableSingleOutputEquals(testee.refresh(ignoreCache: false), true) + XCTAssertFinish(testee.refresh(ignoreCache: false)) XCTAssertFirstValue(testee.todos) { todos in XCTAssertEqual(todos, []) } @@ -100,9 +98,7 @@ class TodoInteractorLiveTests: CoreTestCase { mockPlannables(plannables, contextCodes: makeContextCodes(courseIds: ["1"])) // Then - XCTAssertFirstValueAndCompletion(testee.refresh(ignoreCache: false)) { isEmpty in - XCTAssertFalse(isEmpty) - } + XCTAssertFinish(testee.refresh(ignoreCache: false)) XCTAssertFirstValue(testee.todos) { todos in XCTAssertEqual(todos.count, 1) XCTAssertEqual(todos[0].title, "Assignment 1") @@ -130,14 +126,10 @@ class TodoInteractorLiveTests: CoreTestCase { ), expectation: plannablesAPICallExpectation, value: plannables) // Then - First call with ignoreCache: false - XCTAssertFirstValueAndCompletion(testee.refresh(ignoreCache: false)) { isEmpty in - XCTAssertFalse(isEmpty) - } + XCTAssertFinish(testee.refresh(ignoreCache: false)) // Then - Second call with ignoreCache: true should trigger API calls again - XCTAssertFirstValueAndCompletion(testee.refresh(ignoreCache: true)) { isEmpty in - XCTAssertFalse(isEmpty) - } + XCTAssertFinish(testee.refresh(ignoreCache: true)) wait(for: [coursesAPICallExpectation, plannablesAPICallExpectation], timeout: 1.0) @@ -159,9 +151,7 @@ class TodoInteractorLiveTests: CoreTestCase { mockPlannables(plannables, contextCodes: makeContextCodes(courseIds: ["1"])) // Then - XCTAssertFirstValueAndCompletion(testee.refresh(ignoreCache: false)) { isEmpty in - XCTAssertFalse(isEmpty) - } + XCTAssertFinish(testee.refresh(ignoreCache: false)) XCTAssertFirstValue(testee.todos) { todos in XCTAssertEqual(todos.count, 1) XCTAssertEqual(todos[0].title, "Assignment 2") @@ -192,9 +182,7 @@ class TodoInteractorLiveTests: CoreTestCase { mockPlannables(plannables, contextCodes: makeContextCodes(courseIds: ["1"]), startDate: startDate, endDate: endDate) // Then - XCTAssertFirstValueAndCompletion(testee.refresh(ignoreCache: false)) { isEmpty in - XCTAssertFalse(isEmpty) - } + XCTAssertFinish(testee.refresh(ignoreCache: false)) XCTAssertFirstValue(testee.todos) { todos in XCTAssertEqual(todos.count, 1) } diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift index c353239d5b..c8c40086e7 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift @@ -28,16 +28,16 @@ final class TodoInteractorMock: TodoInteractor { var refreshCalled = false var refreshCallCount = 0 var lastIgnoreCache = false - var refreshResult: Result = .success(false) + var refreshResult: Result = .success(()) - func refresh(ignoreCache: Bool) -> AnyPublisher { + func refresh(ignoreCache: Bool) -> AnyPublisher { refreshCalled = true refreshCallCount += 1 lastIgnoreCache = ignoreCache switch refreshResult { - case .success(let isEmpty): - return Just(isEmpty) + case .success: + return Just(()) .setFailureType(to: Error.self) .eraseToAnyPublisher() case .failure(let error): diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift index 4323da1789..1f18566f1e 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift @@ -44,7 +44,7 @@ class TodoListViewModelTests: CoreTestCase { func testInitialState() { XCTAssertEqual(testee.items, []) - XCTAssertEqual(testee.state, .data) + XCTAssertEqual(testee.state, .empty) } func testInitialRefreshCalled() { @@ -71,7 +71,7 @@ class TodoListViewModelTests: CoreTestCase { func testRefreshWithIgnoreCacheTrue() { // Given let expectation = expectation(description: "Refresh completion called") - interactor.refreshResult = .success(false) + interactor.refreshResult = .success(()) // When testee.refresh(completion: { @@ -83,13 +83,13 @@ class TodoListViewModelTests: CoreTestCase { XCTAssertTrue(interactor.lastIgnoreCache) waitForExpectations(timeout: 1.0) - XCTAssertEqual(testee.state, .data) + XCTAssertEqual(testee.state, .empty) } func testRefreshWithIgnoreCacheFalse() { // Given let expectation = expectation(description: "Refresh completion called") - interactor.refreshResult = .success(false) + interactor.refreshResult = .success(()) // When testee.refresh(completion: { @@ -106,7 +106,8 @@ class TodoListViewModelTests: CoreTestCase { func testRefreshSuccessWithNonEmptyData() { // Given let expectation = expectation(description: "Refresh completion called") - interactor.refreshResult = .success(false) + interactor.refreshResult = .success(()) + interactor.todosSubject.send([TodoItem.make(id: "1", title: "Test Item")]) // When testee.refresh(completion: { @@ -121,7 +122,8 @@ class TodoListViewModelTests: CoreTestCase { func testRefreshSuccessWithEmptyData() { // Given let expectation = expectation(description: "Refresh completion called") - interactor.refreshResult = .success(true) + interactor.refreshResult = .success(()) + interactor.todosSubject.send([]) // When testee.refresh(completion: { @@ -206,23 +208,25 @@ class TodoListViewModelTests: CoreTestCase { } func testStateUpdatesCorrectly() { - XCTAssertEqual(testee.state, .data) + XCTAssertEqual(testee.state, .empty) - // When - interactor.refreshResult = .success(false) + // When - with non-empty todos + interactor.refreshResult = .success(()) + interactor.todosSubject.send([TodoItem.make(id: "1", title: "Test")]) testee.refresh(completion: {}, ignoreCache: false) // Then XCTAssertEqual(testee.state, .data) - // When - interactor.refreshResult = .success(true) + // When - with empty todos + interactor.refreshResult = .success(()) + interactor.todosSubject.send([]) testee.refresh(completion: {}, ignoreCache: false) // Then XCTAssertEqual(testee.state, .empty) - // When + // When - with error interactor.refreshResult = .failure(NSError.internalError()) testee.refresh(completion: {}, ignoreCache: false) @@ -233,7 +237,7 @@ class TodoListViewModelTests: CoreTestCase { func testMultipleRefreshCalls() { // Given interactor.refreshCallCount = 0 - interactor.refreshResult = .success(false) + interactor.refreshResult = .success(()) // When testee.refresh(completion: {}, ignoreCache: false) From 292f4f98a998ac111a8227a78b45543092d82cb5 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Fri, 26 Sep 2025 15:56:31 +0200 Subject: [PATCH 03/49] Add tab and application badge update logic. --- .../AppEnvironment/TabBarBadgeCounts.swift | 6 +++++- .../Features/Todos/Model/TodoInteractor.swift | 5 +++-- .../Todos/Model/TodoInteractorLiveTests.swift | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Core/Core/Common/CommonModels/AppEnvironment/TabBarBadgeCounts.swift b/Core/Core/Common/CommonModels/AppEnvironment/TabBarBadgeCounts.swift index dd838fa78b..2fdc0e19fd 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/TabBarBadgeCounts.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/TabBarBadgeCounts.swift @@ -51,7 +51,11 @@ public class TabBarBadgeCounts: NSObject { private static func updateApplicationIconBadgeNumber() { let count = Int(unreadMessageCount + todoListCount + unreadActivityStreamCount) - notificationCenter.setBadgeCount(count) { _ in } + notificationCenter.setBadgeCount(count) { error in + guard let error else { return } + RemoteLogger.shared.logError(name: "Failed to update app badge count", reason: error.localizedDescription) + Logger.shared.error(error) + } } private static func updateUnreadMessageCount() { diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index 2156f9ee9c..3770ce7832 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -57,8 +57,9 @@ public final class TodoInteractorLive: TodoInteractor { .getEntities(ignoreCache: ignoreCache, loadAllPages: true) .map { $0.compactMap(TodoItem.init) } } - .map { [weak self] (todos: [TodoItem]) in - self?.todosSubject.value = todos + .map { [weak todosSubject] todos in + TabBarBadgeCounts.todoListCount = UInt(todos.count) + todosSubject?.value = todos return () } .eraseToAnyPublisher() diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift index 17b9dfc806..a9cddb2e7c 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift @@ -188,6 +188,25 @@ class TodoInteractorLiveTests: CoreTestCase { } } + func testRefreshUpdatesTabBarBadgeCount() { + // Given + let courses = [makeCourse(id: "1", name: "Course 1")] + let plannables = [ + makePlannable(courseId: "1", plannableId: "p1", type: "assignment", title: "Assignment 1"), + makePlannable(courseId: "1", plannableId: "p2", type: "quiz", title: "Quiz 1"), + makePlannable(courseId: "1", plannableId: "p3", type: "discussion", title: "Discussion 1") + ] + TabBarBadgeCounts.todoListCount = 0 + + // When + mockCourses(courses) + mockPlannables(plannables, contextCodes: makeContextCodes(courseIds: ["1"])) + XCTAssertFinish(testee.refresh(ignoreCache: false)) + + // Then + XCTAssertEqual(TabBarBadgeCounts.todoListCount, 3) + } + // MARK: - Helpers private func mockCourses(_ courses: [APICourse]) { From 5c2ac15759dcc78667fee714daaa30ffded45f09 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Fri, 26 Sep 2025 16:31:44 +0200 Subject: [PATCH 04/49] Use +-28 days interval for todo fetching. --- .../Features/Todos/Model/TodoInteractor.swift | 15 +++++++------- .../Todos/Model/TodoInteractorLiveTests.swift | 20 ++++++++++--------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index 3770ce7832..fe254603fd 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -30,20 +30,19 @@ public final class TodoInteractorLive: TodoInteractor { } private let todosSubject = CurrentValueSubject<[TodoItem], Never>([]) - private let startDate: Date - private let endDate: Date private let env: AppEnvironment private var subscriptions = Set() - init(startDate: Date = .now, endDate: Date = .distantFuture, env: AppEnvironment) { - self.startDate = startDate - self.endDate = endDate + init(env: AppEnvironment) { self.env = env } public func refresh(ignoreCache: Bool) -> AnyPublisher { - ReactiveStore(useCase: GetCourses()) + let startDate = Clock.now.addDays(-28) + let endDate = Clock.now.addDays(28) + + return ReactiveStore(useCase: GetCourses()) .getEntities(ignoreCache: ignoreCache) .map { var contextCodes: [String] = $0.filter(\.isPublished).map(\.canvasContextID) @@ -53,11 +52,11 @@ public final class TodoInteractorLive: TodoInteractor { return contextCodes } .flatMap { codes in - return ReactiveStore(useCase: GetPlannables(startDate: self.startDate, endDate: self.endDate, contextCodes: codes)) + return ReactiveStore(useCase: GetPlannables(startDate: startDate, endDate: endDate, contextCodes: codes)) .getEntities(ignoreCache: ignoreCache, loadAllPages: true) .map { $0.compactMap(TodoItem.init) } } - .map { [weak todosSubject] todos in + .map { [weak todosSubject] (todos: [TodoItem]) in TabBarBadgeCounts.todoListCount = UInt(todos.count) todosSubject?.value = todos return () diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift index a9cddb2e7c..fdcf7a9666 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift @@ -24,17 +24,20 @@ import Combine class TodoInteractorLiveTests: CoreTestCase { private var testee: TodoInteractorLive! + private static let mockDate = Date.make(year: 2025, month: 1, day: 15, hour: 12) // MARK: - Setup and teardown override func setUp() { super.setUp() + Clock.mockNow(Self.mockDate) environment.currentSession = LoginSession.make(userID: "1") testee = TodoInteractorLive(env: environment) } override func tearDown() { testee = nil + Clock.reset() super.tearDown() } @@ -120,8 +123,8 @@ class TodoInteractorLiveTests: CoreTestCase { api.mock(GetCoursesRequest(enrollmentState: .active, perPage: 100), expectation: coursesAPICallExpectation, value: courses) api.mock(GetPlannablesRequest( userID: nil, - startDate: Date.now, - endDate: Date.distantFuture, + startDate: Clock.now.addDays(-28), + endDate: Clock.now.addDays(28), contextCodes: makeContextCodes(courseIds: ["1"]) ), expectation: plannablesAPICallExpectation, value: plannables) @@ -169,17 +172,16 @@ class TodoInteractorLiveTests: CoreTestCase { } } - func testCustomDateRange() { + func testRefreshUsesDefaultDateRange() { // Given - let startDate = Clock.now.addDays(-1) - let endDate = Clock.now.addDays(7) let courses = [makeCourse(id: "1", name: "Course 1")] let plannables = [makePlannable(courseId: "1", plannableId: "p1", type: "assignment", title: "Assignment 1")] + let expectedStartDate = Clock.now.addDays(-28) + let expectedEndDate = Clock.now.addDays(28) // When - testee = TodoInteractorLive(startDate: startDate, endDate: endDate, env: environment) mockCourses(courses) - mockPlannables(plannables, contextCodes: makeContextCodes(courseIds: ["1"]), startDate: startDate, endDate: endDate) + mockPlannables(plannables, contextCodes: makeContextCodes(courseIds: ["1"]), startDate: expectedStartDate, endDate: expectedEndDate) // Then XCTAssertFinish(testee.refresh(ignoreCache: false)) @@ -216,8 +218,8 @@ class TodoInteractorLiveTests: CoreTestCase { private func mockPlannables(_ plannables: [APIPlannable], contextCodes: [String]) { api.mock(GetPlannablesRequest( userID: nil, - startDate: Date.now, - endDate: Date.distantFuture, + startDate: Clock.now.addDays(-28), + endDate: Clock.now.addDays(28), contextCodes: contextCodes ), value: plannables) } From 1feaef4df00696b5618ba783e24111cd13f322e5 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Fri, 26 Sep 2025 16:53:22 +0200 Subject: [PATCH 05/49] Use local env variable for fetching. --- .../Features/Todos/Model/TodoInteractor.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index fe254603fd..be6934cbbe 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -41,20 +41,24 @@ public final class TodoInteractorLive: TodoInteractor { public func refresh(ignoreCache: Bool) -> AnyPublisher { let startDate = Clock.now.addDays(-28) let endDate = Clock.now.addDays(28) + let currentUserID = env.currentSession?.userID - return ReactiveStore(useCase: GetCourses()) + return ReactiveStore(useCase: GetCourses(), environment: env) .getEntities(ignoreCache: ignoreCache) .map { var contextCodes: [String] = $0.filter(\.isPublished).map(\.canvasContextID) - if let userContextCode = Context(.user, id: self.env.currentSession?.userID)?.canvasContextID { + if let userContextCode = Context(.user, id: currentUserID)?.canvasContextID { contextCodes.append(userContextCode) } return contextCodes } - .flatMap { codes in - return ReactiveStore(useCase: GetPlannables(startDate: startDate, endDate: endDate, contextCodes: codes)) - .getEntities(ignoreCache: ignoreCache, loadAllPages: true) - .map { $0.compactMap(TodoItem.init) } + .flatMap { [env] codes in + ReactiveStore( + useCase: GetPlannables(startDate: startDate, endDate: endDate, contextCodes: codes), + environment: env + ) + .getEntities(ignoreCache: ignoreCache, loadAllPages: true) + .map { $0.compactMap(TodoItem.init) } } .map { [weak todosSubject] (todos: [TodoItem]) in TabBarBadgeCounts.todoListCount = UInt(todos.count) From 7a4813a26e5b6e9ba8b434b8ffb5c84f96761c57 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Fri, 26 Sep 2025 17:36:51 +0200 Subject: [PATCH 06/49] Extract todo cell content view to Core. --- Core/Core/Features/Todos/Model/TodoItem.swift | 10 ++- .../Todos/View/TodoItemContentView.swift | 85 +++++++++++++++++++ .../View/Components/TodoItemView.swift | 65 ++------------ 3 files changed, 97 insertions(+), 63 deletions(-) create mode 100644 Core/Core/Features/Todos/View/TodoItemContentView.swift diff --git a/Core/Core/Features/Todos/Model/TodoItem.swift b/Core/Core/Features/Todos/Model/TodoItem.swift index 62b78e860a..7d941c44ed 100644 --- a/Core/Core/Features/Todos/Model/TodoItem.swift +++ b/Core/Core/Features/Todos/Model/TodoItem.swift @@ -74,13 +74,15 @@ public struct TodoItem: Identifiable, Equatable { // MARK: Preview & Testing + #if DEBUG + public static func make( - id: String = "", + id: String = "1", type: PlannableType = .assignment, date: Date = Clock.now, - title: String = "", + title: String = "Calculate how far the Millennium Falcon actually traveled in less than 12 parsecs", subtitle: String? = nil, - contextName: String = "", + contextName: String = "Math II.", htmlURL: URL? = nil, color: Color = .red, icon: Image = .assignmentLine @@ -98,4 +100,6 @@ public struct TodoItem: Identifiable, Equatable { icon: icon ) } + + #endif } diff --git a/Core/Core/Features/Todos/View/TodoItemContentView.swift b/Core/Core/Features/Todos/View/TodoItemContentView.swift new file mode 100644 index 0000000000..6543d42e10 --- /dev/null +++ b/Core/Core/Features/Todos/View/TodoItemContentView.swift @@ -0,0 +1,85 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import SwiftUI + +/// A reusable view component that displays the visual content of a TodoItem. +/// This component is used on the Todo List screen and on the Todo widget. +public struct TodoItemContentView: View { + @ScaledMetric private var uiScale: CGFloat = 1 + + public let item: TodoItem + + public init(item: TodoItem) { + self.item = item + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + contextSection + titleSection + timeSection + } + } + + private var contextSection: some View { + HStack(spacing: 5) { + item.icon + .scaledIcon(size: 16) + .foregroundStyle(item.color) + .accessibilityHidden(true) + InstUI.Divider().frame(maxHeight: 16 * uiScale) + Text(item.contextName) + .foregroundStyle(item.color) + .font(.regular12, lineHeight: .fit) + .lineLimit(1) + } + } + + private var titleSection: some View { + VStack(alignment: .leading) { + Text(item.title) + .font(.semibold14, lineHeight: .fit) + .foregroundStyle(.textDarkest) + .lineLimit(1) + if let subtitle = item.subtitle { + Text(subtitle) + .font(.regular12, lineHeight: .fit) + .foregroundStyle(.textDark) + .lineLimit(1) + } + } + } + + private var timeSection: some View { + Text(item.date.dateTimeStringShort) + .font(.regular12) + .foregroundStyle(.textDark) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#if DEBUG + +#Preview { + TodoItemContentView(item: .make()) + TodoItemContentView(item: .make(subtitle: "This is a subtitle")) +} + +#endif diff --git a/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift b/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift index 9ccd81efed..554fb8bdf1 100644 --- a/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift +++ b/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift @@ -27,72 +27,17 @@ struct TodoItemView: View { var body: some View { Link(destination: item.route) { - VStack(alignment: .leading, spacing: 0) { - contextSection - titleSection - timeSection - } + TodoItemContentView(item: item) } } - - private var contextSection: some View { - HStack(spacing: 5) { - item.icon - .scaledIcon(size: 16) - .foregroundStyle(item.color) - .accessibilityHidden(true) - InstUI.Divider().frame(maxHeight: 16 * uiScale) - Text(item.contextName) - .foregroundStyle(item.color) - .font(.regular12) - .lineLimit(1) - } - } - - private var titleSection: some View { - VStack(alignment: .leading) { - Text(item.title) - .font(.semibold14) - .foregroundStyle(Color.textDarkest) - .lineLimit(1) - if let subtitle = item.subtitle { - Text(subtitle) - .font(.regular12) - .foregroundStyle(Color.textDark) - .lineLimit(1) - } - } - } - - private var timeSection: some View { - Text(item.date.formatted(.dateTime.hour().minute())) - .font(.regular12) - .foregroundStyle(Color.textDark) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityLabel(timeAccessibilityLabel) - } - - private var timeAccessibilityLabel: String { - let format = Date.FormatStyle - .dateTime - .year() - .month(.wide) - .day() - .hour() - .minute() - return item.date.formatted(format) - } - - private var isToday: Bool { - return item.date.startOfDay() == Date.now.startOfDay() - } } #if DEBUG -#Preview { - TodoItemView(item: .make()) +#Preview("TodoWidgetData", as: .systemLarge) { + TodoWidget() +} timeline: { + TodoWidgetEntry(data: TodoModel.make(), date: Date()) } #endif From dbc7b0415c21a8238b880f9a41f6dd454572739d Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Mon, 29 Sep 2025 12:23:14 +0200 Subject: [PATCH 07/49] Update todo item layout. --- Core/Core/Features/Todos/Model/TodoItem.swift | 4 +-- .../Todos/View/TodoItemContentView.swift | 31 +++++++++++++------ .../View/Components/TodoItemView.swift | 2 +- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/Core/Core/Features/Todos/Model/TodoItem.swift b/Core/Core/Features/Todos/Model/TodoItem.swift index 7d941c44ed..a2a97ff8bc 100644 --- a/Core/Core/Features/Todos/Model/TodoItem.swift +++ b/Core/Core/Features/Todos/Model/TodoItem.swift @@ -81,8 +81,8 @@ public struct TodoItem: Identifiable, Equatable { type: PlannableType = .assignment, date: Date = Clock.now, title: String = "Calculate how far the Millennium Falcon actually traveled in less than 12 parsecs", - subtitle: String? = nil, - contextName: String = "Math II.", + subtitle: String? = "This is a longer subtitle that should be truncated in compact mode", + contextName: String = "FORC 101 or something longer to even show it in two lines ", htmlURL: URL? = nil, color: Color = .red, icon: Image = .assignmentLine diff --git a/Core/Core/Features/Todos/View/TodoItemContentView.swift b/Core/Core/Features/Todos/View/TodoItemContentView.swift index 6543d42e10..55045f6cd2 100644 --- a/Core/Core/Features/Todos/View/TodoItemContentView.swift +++ b/Core/Core/Features/Todos/View/TodoItemContentView.swift @@ -24,9 +24,15 @@ public struct TodoItemContentView: View { @ScaledMetric private var uiScale: CGFloat = 1 public let item: TodoItem + public let isCompactLayout: Bool - public init(item: TodoItem) { + /// Initializes a TodoItemContentView + /// - Parameters: + /// - item: The TodoItem to display + /// - isCompactLayout: If true, text will be limited to single lines with truncation. If false, text can wrap to multiple lines for full display. + public init(item: TodoItem, isCompactLayout: Bool) { self.item = item + self.isCompactLayout = isCompactLayout } public var body: some View { @@ -35,6 +41,7 @@ public struct TodoItemContentView: View { titleSection timeSection } + .background(Color.backgroundLightest) } private var contextSection: some View { @@ -43,12 +50,14 @@ public struct TodoItemContentView: View { .scaledIcon(size: 16) .foregroundStyle(item.color) .accessibilityHidden(true) - InstUI.Divider().frame(maxHeight: 16 * uiScale) + .frame(maxHeight: .infinity, alignment: .top) + InstUI.Divider() Text(item.contextName) .foregroundStyle(item.color) .font(.regular12, lineHeight: .fit) - .lineLimit(1) + .lineLimit(isCompactLayout ? 1 : nil) } + .fixedSize(horizontal: false, vertical: true) } private var titleSection: some View { @@ -56,12 +65,12 @@ public struct TodoItemContentView: View { Text(item.title) .font(.semibold14, lineHeight: .fit) .foregroundStyle(.textDarkest) - .lineLimit(1) + .lineLimit(isCompactLayout ? 1 : nil) if let subtitle = item.subtitle { Text(subtitle) .font(.regular12, lineHeight: .fit) .foregroundStyle(.textDark) - .lineLimit(1) + .lineLimit(isCompactLayout ? 1 : nil) } } } @@ -70,16 +79,20 @@ public struct TodoItemContentView: View { Text(item.date.dateTimeStringShort) .font(.regular12) .foregroundStyle(.textDark) - .lineLimit(1) + .lineLimit(isCompactLayout ? 1 : nil) .frame(maxWidth: .infinity, alignment: .leading) } } #if DEBUG -#Preview { - TodoItemContentView(item: .make()) - TodoItemContentView(item: .make(subtitle: "This is a subtitle")) +#Preview(traits: .fixedLayout(width: 300, height: 400)) { + VStack { + TodoItemContentView(item: .make(), isCompactLayout: true) + TodoItemContentView(item: .make(), isCompactLayout: false) + } + .frame(maxHeight: .infinity) + .background(Color.backgroundDarkest) } #endif diff --git a/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift b/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift index 554fb8bdf1..3eb3dd9e53 100644 --- a/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift +++ b/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift @@ -27,7 +27,7 @@ struct TodoItemView: View { var body: some View { Link(destination: item.route) { - TodoItemContentView(item: item) + TodoItemContentView(item: item, isCompactLayout: true) } } } From 9d217ccec011a711984a2d5f16cb4f904b3cf9d1 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Mon, 29 Sep 2025 13:56:40 +0200 Subject: [PATCH 08/49] Use shared todo cells for widget and todo screen. --- .../Features/Todos/Model/TodoInteractor.swift | 2 +- Core/Core/Features/Todos/Model/TodoItem.swift | 49 ++++++++++++++++- .../Todos/View/TodoItemContentView.swift | 1 + .../Todos/View/TodoListItemCell.swift | 53 ++++++------------- .../Features/Todos/View/TodoListScreen.swift | 17 +++--- 5 files changed, 78 insertions(+), 44 deletions(-) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index be6934cbbe..5baf2e2eb3 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -75,7 +75,7 @@ public final class TodoInteractorPreview: TodoInteractor { public let todos: AnyPublisher<[TodoItem], Never> public init(todos: [TodoItem] = []) { - let todos: [TodoItem] = todos.isEmpty ? [.make(id: "1"), .make(id: "2")] : todos + let todos: [TodoItem] = todos.isEmpty ? [.makeShortText(id: "1"), .makeLongText(id: "2")] : todos self.todos = Publishers.typedJust(todos) } diff --git a/Core/Core/Features/Todos/Model/TodoItem.swift b/Core/Core/Features/Todos/Model/TodoItem.swift index a2a97ff8bc..c689e566fb 100644 --- a/Core/Core/Features/Todos/Model/TodoItem.swift +++ b/Core/Core/Features/Todos/Model/TodoItem.swift @@ -82,12 +82,59 @@ public struct TodoItem: Identifiable, Equatable { date: Date = Clock.now, title: String = "Calculate how far the Millennium Falcon actually traveled in less than 12 parsecs", subtitle: String? = "This is a longer subtitle that should be truncated in compact mode", - contextName: String = "FORC 101 or something longer to even show it in two lines ", + contextName: String = "FORC 101 or something longer to even show it in two lines", htmlURL: URL? = nil, color: Color = .red, icon: Image = .assignmentLine ) -> TodoItem { + TodoItem( + id: id, + type: type, + date: date, + title: title, + subtitle: subtitle, + contextName: contextName, + htmlURL: htmlURL, + color: color, + icon: icon + ) + } + + public static func makeShortText( + id: String = "1", + type: PlannableType = .assignment, + date: Date = Clock.now, + title: String = "Quiz 1", + subtitle: String? = "Due today", + contextName: String = "Math 101", + htmlURL: URL? = nil, + color: Color = .blue, + icon: Image = .quizLine + ) -> TodoItem { + TodoItem( + id: id, + type: type, + date: date, + title: title, + subtitle: subtitle, + contextName: contextName, + htmlURL: htmlURL, + color: color, + icon: icon + ) + } + public static func makeLongText( + id: String = "1", + type: PlannableType = .assignment, + date: Date = Clock.now, + title: String = "Complete comprehensive reading assignment covering advanced mathematical concepts and theoretical applications", + subtitle: String? = "Read chapters 5-7 including all exercises, supplementary materials, and prepare detailed notes for the upcoming examination period", + contextName: String = "Advanced Mathematics and Theoretical Applications - Professor Johnson", + htmlURL: URL? = nil, + color: Color = .green, + icon: Image = .assignmentLine + ) -> TodoItem { TodoItem( id: id, type: type, diff --git a/Core/Core/Features/Todos/View/TodoItemContentView.swift b/Core/Core/Features/Todos/View/TodoItemContentView.swift index 55045f6cd2..244fadc3b2 100644 --- a/Core/Core/Features/Todos/View/TodoItemContentView.swift +++ b/Core/Core/Features/Todos/View/TodoItemContentView.swift @@ -42,6 +42,7 @@ public struct TodoItemContentView: View { timeSection } .background(Color.backgroundLightest) + .multilineTextAlignment(.leading) } private var contextSection: some View { diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index 99c5e64cbf..9066849ebd 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -24,52 +24,33 @@ struct TodoListItemCell: View { let item: TodoItem let onTap: (_ item: TodoItem, _ viewController: WeakViewController) -> Void - let isLastItem: Bool var body: some View { VStack(spacing: 0) { Button { onTap(item, viewController) } label: { - HStack(alignment: .top, spacing: 0) { - item.icon - .scaledIcon() - .foregroundStyle(item.color) - .paddingStyle(.trailing, .cellIconText) + HStack(spacing: 0) { + TodoItemContentView(item: item, isCompactLayout: false) + InstUI.DisclosureIndicator() + .paddingStyle(.leading, .cellAccessoryPadding) .accessibilityHidden(true) - - HStack(alignment: .center) { - VStack(alignment: .leading, spacing: 4) { - Text(item.contextName) - .font(.regular14) - .foregroundStyle(item.color) - - Text(item.title) - .font(.regular16) - .foregroundStyle(.textDarkest) - - if let subtitle = item.subtitle { - Text(subtitle) - .font(.regular14) - .foregroundStyle(.textDark) - } - - Text(item.date.dateTimeStringShort) - .font(.regular14) - .foregroundStyle(.textDark) - } - .frame(maxWidth: .infinity, alignment: .leading) - - InstUI.DisclosureIndicator() - .paddingStyle(.leading, .cellAccessoryPadding) - .accessibilityHidden(true) - } - .multilineTextAlignment(.leading) } - .paddingStyle(set: .iconCell) } .accessibilityElement(children: .combine) - InstUI.Divider(isLastItem ? .full : .padded) } + .padding(.vertical, 8) } } + +#if DEBUG + +#Preview { + VStack(spacing: 0) { + TodoListItemCell(item: .makeShortText(), onTap: { _, _ in }) + TodoListItemCell(item: .makeLongText(), onTap: { _, _ in }) + } + .background(Color.backgroundLightest) +} + +#endif diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index d534ab90f3..9bfa0c1d91 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -34,12 +34,17 @@ public struct TodoListScreen: View { viewModel.refresh(completion: completion, ignoreCache: true) } ) { _ in - ForEach(viewModel.items) { item in - TodoListItemCell( - item: item, - onTap: viewModel.didTapItem, - isLastItem: viewModel.items.last == item - ) + LazyVStack(spacing: 0) { + ForEach(viewModel.items) { item in + TodoListItemCell( + item: item, + onTap: viewModel.didTapItem + ) + .padding(.leading, 64) + let isLastItem = (viewModel.items.last == item) + InstUI.Divider().padding(.leading, isLastItem ? 0 : 64) + } + .paddingStyle(.horizontal, .standard) } } .navigationBarItems(leading: profileMenuButton) From 892aaf9b02c9ccd8d8fd219213d792c1f63eaa35 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Mon, 29 Sep 2025 18:38:24 +0200 Subject: [PATCH 09/49] Add sticky day headers. --- .../Foundation/DateExtensions.swift | 16 ++++ .../Core/Features/Todos/Model/TodoGroup.swift | 39 +++++++++ .../Features/Todos/Model/TodoInteractor.swift | 56 ++++++++++--- .../Todos/View/TodoDayHeaderView.swift | 81 +++++++++++++++++++ .../Todos/View/TodoListItemCell.swift | 1 - .../Features/Todos/View/TodoListScreen.swift | 38 ++++++--- .../Todos/ViewModel/TodoListViewModel.swift | 4 +- .../Foundation/DateExtensionsTests.swift | 5 ++ .../Todos/Model/TodoInteractorLiveTests.swift | 43 ++++++---- .../Todos/Model/TodoInteractorMock.swift | 6 +- .../ViewModel/TodoListViewModelTests.swift | 21 ++--- 11 files changed, 257 insertions(+), 53 deletions(-) create mode 100644 Core/Core/Features/Todos/Model/TodoGroup.swift create mode 100644 Core/Core/Features/Todos/View/TodoDayHeaderView.swift diff --git a/Core/Core/Common/Extensions/Foundation/DateExtensions.swift b/Core/Core/Common/Extensions/Foundation/DateExtensions.swift index 9ecddfe9ca..c42a7e4819 100644 --- a/Core/Core/Common/Extensions/Foundation/DateExtensions.swift +++ b/Core/Core/Common/Extensions/Foundation/DateExtensions.swift @@ -178,6 +178,15 @@ public extension Date { return formatter }() + /** + This date formatter displays abbreviated weekday names. E.g.: Mon, Wed, Sat. + */ + private static var weekdayFormatterAbbreviated: DateFormatter = { + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("EEE") + return formatter + }() + /** This date formatter displays the full month name and the day of the month. E.g.: September 6. */ @@ -276,6 +285,13 @@ public extension Date { Date.weekdayFormatter.string(from: self) } + /** + E.g.: Mon, Wed, Sat + */ + var weekdayNameAbbreviated: String { + Date.weekdayFormatterAbbreviated.string(from: self) + } + /** E.g.: September 6. */ diff --git a/Core/Core/Features/Todos/Model/TodoGroup.swift b/Core/Core/Features/Todos/Model/TodoGroup.swift new file mode 100644 index 0000000000..f4b3a0c896 --- /dev/null +++ b/Core/Core/Features/Todos/Model/TodoGroup.swift @@ -0,0 +1,39 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Foundation + +public struct TodoGroup: Identifiable, Equatable { + public let id: String + public let date: Date + public let items: [TodoItem] + public let weekdayAbbreviation: String + public let dayNumber: String + public let isToday: Bool + public let displayDate: String + + public init(date: Date, items: [TodoItem]) { + self.id = date.isoString() + self.date = date + self.items = items + self.weekdayAbbreviation = date.weekdayNameAbbreviated + self.dayNumber = date.dayString + self.isToday = Cal.currentCalendar.isDateInToday(date) + self.displayDate = date.dayInMonth + } +} diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index 5baf2e2eb3..ec251aabf4 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -20,16 +20,16 @@ import Foundation import Combine public protocol TodoInteractor { - var todos: AnyPublisher<[TodoItem], Never> { get } + var todoGroups: AnyPublisher<[TodoGroup], Never> { get } func refresh(ignoreCache: Bool) -> AnyPublisher } public final class TodoInteractorLive: TodoInteractor { - public var todos: AnyPublisher<[TodoItem], Never> { - todosSubject.eraseToAnyPublisher() + public var todoGroups: AnyPublisher<[TodoGroup], Never> { + todoGroupsSubject.eraseToAnyPublisher() } - private let todosSubject = CurrentValueSubject<[TodoItem], Never>([]) + private let todoGroupsSubject = CurrentValueSubject<[TodoGroup], Never>([]) private let env: AppEnvironment private var subscriptions = Set() @@ -60,23 +60,59 @@ public final class TodoInteractorLive: TodoInteractor { .getEntities(ignoreCache: ignoreCache, loadAllPages: true) .map { $0.compactMap(TodoItem.init) } } - .map { [weak todosSubject] (todos: [TodoItem]) in + .map { [weak todoGroupsSubject] (todos: [TodoItem]) in TabBarBadgeCounts.todoListCount = UInt(todos.count) - todosSubject?.value = todos + + // Group todos by day + let groupedTodos = Self.groupTodosByDay(todos) + todoGroupsSubject?.value = groupedTodos return () } .eraseToAnyPublisher() } + + private static func groupTodosByDay(_ todos: [TodoItem]) -> [TodoGroup] { + // Group todos by day using existing Canvas extension + let groupedDict = Dictionary(grouping: todos) { todo in + todo.date.startOfDay() + } + + // Convert to TodoGroup array and sort by date + return groupedDict.map { (date, items) in + TodoGroup(date: date, items: items.sorted { $0.date < $1.date }) + } + .sorted { $0.date < $1.date } + } } #if DEBUG public final class TodoInteractorPreview: TodoInteractor { - public let todos: AnyPublisher<[TodoItem], Never> + public let todoGroups: AnyPublisher<[TodoGroup], Never> + + public init(todoGroups: [TodoGroup] = []) { + if todoGroups.isNotEmpty { + self.todoGroups = Publishers.typedJust(todoGroups) + return + } + + let today = Calendar.current.startOfDay(for: Date()) + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today) ?? today - public init(todos: [TodoItem] = []) { - let todos: [TodoItem] = todos.isEmpty ? [.makeShortText(id: "1"), .makeLongText(id: "2")] : todos - self.todos = Publishers.typedJust(todos) + let todayGroup = TodoGroup( + date: today, + items: [ + .makeShortText(id: "3") + ] + ) + let tomorrowGroup = TodoGroup( + date: tomorrow, + items: [ + .makeShortText(id: "1"), + .makeLongText(id: "2") + ] + ) + self.todoGroups = Publishers.typedJust([todayGroup, tomorrowGroup]) } public func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift new file mode 100644 index 0000000000..731b094485 --- /dev/null +++ b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift @@ -0,0 +1,81 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import SwiftUI + +struct TodoDayHeaderView: View { + let group: TodoGroup + let tintColor: Color + let circleSize: CGFloat + let circleOpacity: CGFloat + + init(group: TodoGroup) { + self.group = group + self.tintColor = group.isToday ? Color.accentColor : .textDark + self.circleSize = group.isToday ? 32 : 0 + self.circleOpacity = group.isToday ? 1 : 0 + } + + var body: some View { + Button { + + } label: { + VStack(spacing: 0) { + Text(group.weekdayAbbreviation) + .font(.regular12, lineHeight: .fit) + Text(group.dayNumber) + .font(group.isToday ? .bold12 : .regular12, lineHeight: .fit) + .frame(minWidth: circleSize, minHeight: circleSize) + .background(Circle().stroke(tintColor).opacity(circleOpacity)) + .padding(.top, group.isToday ? 0 : -2) + } + .padding(.top, 8) + .frame(width: 64, alignment: .center) + .frame(minHeight: 48, alignment: .top) + .foregroundStyle(tintColor) + .contentShape(Rectangle()) + } + .background(Color.backgroundLightest) + .buttonStyle(.plain) + } +} + +#if DEBUG + +#Preview { + let today = Calendar.current.startOfDay(for: Date()) + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() + let todayGroup = TodoGroup( + date: today, + items: [.makeShortText(id: "1")] + ) + let tomorrowGroup = TodoGroup( + date: tomorrow, + items: [.makeShortText(id: "1")] + ) + + HStack(spacing: 0) { + TodoDayHeaderView(group: todayGroup) + TodoDayHeaderView(group: tomorrowGroup) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.backgroundDarkest) + .tint(.course1) +} + +#endif diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index 9066849ebd..0f977be1af 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -39,7 +39,6 @@ struct TodoListItemCell: View { } .accessibilityElement(children: .combine) } - .padding(.vertical, 8) } } diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 9bfa0c1d91..3e7aacc25a 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -34,22 +34,40 @@ public struct TodoListScreen: View { viewModel.refresh(completion: completion, ignoreCache: true) } ) { _ in - LazyVStack(spacing: 0) { - ForEach(viewModel.items) { item in - TodoListItemCell( - item: item, - onTap: viewModel.didTapItem - ) - .padding(.leading, 64) - let isLastItem = (viewModel.items.last == item) - InstUI.Divider().padding(.leading, isLastItem ? 0 : 64) + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(viewModel.items) { group in + groupView(for: group) } - .paddingStyle(.horizontal, .standard) } + .paddingStyle(.horizontal, .standard) } + .clipped() .navigationBarItems(leading: profileMenuButton) } + @ViewBuilder + private func groupView(for group: TodoGroup) -> some View { + Section { + ForEach(group.items) { item in + TodoListItemCell( + item: item, + onTap: viewModel.didTapItem + ) + .padding(.vertical, 8) + .padding(.leading, 64) + + let isLastItemInGroup = (group.items.last == item) + InstUI.Divider().padding(.leading, isLastItemInGroup ? 0 : 64) + } + } header: { + TodoDayHeaderView(group: group) + // move day badge to the left + .frame(maxWidth: .infinity, alignment: .leading) + // squeeze height to 0 so day badge goes next to cell + .frame(height: 0, alignment: .top) + } + } + private var profileMenuButton: some View { Button { viewModel.openProfile(viewController) diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index 8ad0eb73bd..e7a4645765 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -21,7 +21,7 @@ import Combine import CombineExt public class TodoListViewModel: ObservableObject { - @Published var items: [TodoItem] = [] + @Published var items: [TodoGroup] = [] @Published var state: InstUI.ScreenState = .loading private let interactor: TodoInteractor @@ -32,7 +32,7 @@ public class TodoListViewModel: ObservableObject { self.interactor = interactor self.env = env - interactor.todos + interactor.todoGroups .assign(to: \.items, on: self, ownership: .weak) .store(in: &subscriptions) diff --git a/Core/CoreTests/Common/Extensions/Foundation/DateExtensionsTests.swift b/Core/CoreTests/Common/Extensions/Foundation/DateExtensionsTests.swift index 1661de0d7d..625e1a63e1 100644 --- a/Core/CoreTests/Common/Extensions/Foundation/DateExtensionsTests.swift +++ b/Core/CoreTests/Common/Extensions/Foundation/DateExtensionsTests.swift @@ -137,6 +137,11 @@ class DateExtensionsTests: XCTestCase { XCTAssertEqual(date.weekdayName, "Saturday") } + func testWeekdayAbbreviatedFormatting() { + let date = Date(fromISOString: "2021-08-07T12:00:00Z")! + XCTAssertEqual(date.weekdayNameAbbreviated, "Sat") + } + func testDayInMonthFormatting() { let date = Date(fromISOString: "2021-08-07T12:00:00Z")! XCTAssertEqual(date.dayInMonth, "August 7") diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift index fdcf7a9666..fb2768fc73 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift @@ -37,15 +37,14 @@ class TodoInteractorLiveTests: CoreTestCase { override func tearDown() { testee = nil - Clock.reset() super.tearDown() } // MARK: - Tests func testInitialTodosIsEmpty() { - XCTAssertFirstValue(testee.todos) { todos in - XCTAssertEqual(todos, []) + XCTAssertFirstValue(testee.todoGroups) { todoGroups in + XCTAssertEqual(todoGroups, []) } } @@ -67,10 +66,19 @@ class TodoInteractorLiveTests: CoreTestCase { // Then XCTAssertFinish(testee.refresh(ignoreCache: false)) - XCTAssertFirstValue(testee.todos) { todos in - XCTAssertEqual(todos.count, 2) - XCTAssertEqual(todos[0].title, "Assignment 1") - XCTAssertEqual(todos[1].title, "Quiz 1") + XCTAssertFirstValue(testee.todoGroups) { todoGroups in + // Should have 2 groups (one for each day since plannables are on different days) + XCTAssertEqual(todoGroups.count, 2) + + // Check first group (today) + let firstGroup = todoGroups[0] + XCTAssertEqual(firstGroup.items.count, 1) + XCTAssertEqual(firstGroup.items[0].title, "Assignment 1") + + // Check second group (tomorrow) + let secondGroup = todoGroups[1] + XCTAssertEqual(secondGroup.items.count, 1) + XCTAssertEqual(secondGroup.items[0].title, "Quiz 1") } } @@ -81,7 +89,7 @@ class TodoInteractorLiveTests: CoreTestCase { // Then XCTAssertFinish(testee.refresh(ignoreCache: false)) - XCTAssertFirstValue(testee.todos) { todos in + XCTAssertFirstValue(testee.todoGroups) { todos in XCTAssertEqual(todos, []) } } @@ -102,9 +110,10 @@ class TodoInteractorLiveTests: CoreTestCase { // Then XCTAssertFinish(testee.refresh(ignoreCache: false)) - XCTAssertFirstValue(testee.todos) { todos in - XCTAssertEqual(todos.count, 1) - XCTAssertEqual(todos[0].title, "Assignment 1") + XCTAssertFirstValue(testee.todoGroups) { todoGroups in + XCTAssertEqual(todoGroups.count, 1) + XCTAssertEqual(todoGroups[0].items.count, 1) + XCTAssertEqual(todoGroups[0].items[0].title, "Assignment 1") } } @@ -136,9 +145,9 @@ class TodoInteractorLiveTests: CoreTestCase { wait(for: [coursesAPICallExpectation, plannablesAPICallExpectation], timeout: 1.0) - XCTAssertFirstValue(testee.todos) { todos in + XCTAssertFirstValue(testee.todoGroups) { todos in XCTAssertEqual(todos.count, 1) - XCTAssertEqual(todos[0].title, "Assignment 1") + XCTAssertEqual(todos[0].items[0].title, "Assignment 1") } } @@ -155,9 +164,9 @@ class TodoInteractorLiveTests: CoreTestCase { // Then XCTAssertFinish(testee.refresh(ignoreCache: false)) - XCTAssertFirstValue(testee.todos) { todos in + XCTAssertFirstValue(testee.todoGroups) { todos in XCTAssertEqual(todos.count, 1) - XCTAssertEqual(todos[0].title, "Assignment 2") + XCTAssertEqual(todos[0].items[0].title, "Assignment 2") } } @@ -167,7 +176,7 @@ class TodoInteractorLiveTests: CoreTestCase { // Then XCTAssertFailure(testee.refresh(ignoreCache: false)) - XCTAssertFirstValue(testee.todos) { todos in + XCTAssertFirstValue(testee.todoGroups) { todos in XCTAssertEqual(todos, []) } } @@ -185,7 +194,7 @@ class TodoInteractorLiveTests: CoreTestCase { // Then XCTAssertFinish(testee.refresh(ignoreCache: false)) - XCTAssertFirstValue(testee.todos) { todos in + XCTAssertFirstValue(testee.todoGroups) { todos in XCTAssertEqual(todos.count, 1) } } diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift index c8c40086e7..f6c8e66ecf 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift @@ -20,11 +20,11 @@ import Combine final class TodoInteractorMock: TodoInteractor { - var todos: AnyPublisher<[TodoItem], Never> { - todosSubject.eraseToAnyPublisher() + var todoGroups: AnyPublisher<[TodoGroup], Never> { + todoGroupsSubject.eraseToAnyPublisher() } - let todosSubject = CurrentValueSubject<[TodoItem], Never>([]) + let todoGroupsSubject = CurrentValueSubject<[TodoGroup], Never>([]) var refreshCalled = false var refreshCallCount = 0 var lastIgnoreCache = false diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift index 1f18566f1e..61dfb9d40f 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift @@ -58,13 +58,14 @@ class TodoListViewModelTests: CoreTestCase { TodoItem.make(id: "1", title: "Test Item 1"), TodoItem.make(id: "2", title: "Test Item 2") ] + let testGroups = [TodoGroup(date: Date(), items: testItems)] // When - interactor.todosSubject.send(testItems) + interactor.todoGroupsSubject.send(testGroups) // Then XCTAssertFirstValue(testee.$items) { items in - XCTAssertEqual(items, testItems) + XCTAssertEqual(items, testGroups) } } @@ -107,7 +108,7 @@ class TodoListViewModelTests: CoreTestCase { // Given let expectation = expectation(description: "Refresh completion called") interactor.refreshResult = .success(()) - interactor.todosSubject.send([TodoItem.make(id: "1", title: "Test Item")]) + interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [TodoItem.make(id: "1", title: "Test Item")])]) // When testee.refresh(completion: { @@ -123,7 +124,7 @@ class TodoListViewModelTests: CoreTestCase { // Given let expectation = expectation(description: "Refresh completion called") interactor.refreshResult = .success(()) - interactor.todosSubject.send([]) + interactor.todoGroupsSubject.send([]) // When testee.refresh(completion: { @@ -153,7 +154,7 @@ class TodoListViewModelTests: CoreTestCase { func testDidTapItemPlannerNote() { // Given let todo = TodoItem.make(id: "123", type: .planner_note) - interactor.todosSubject.send([todo]) + interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -170,7 +171,7 @@ class TodoListViewModelTests: CoreTestCase { type: .calendar_event, htmlURL: URL(string: "https://canvas.instructure.com/calendar") ) - interactor.todosSubject.send([todo]) + interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -186,7 +187,7 @@ class TodoListViewModelTests: CoreTestCase { id: "789", type: .assignment, htmlURL: URL(string: "https://canvas.instructure.com/courses/1/assignments/789")) - interactor.todosSubject.send([todo]) + interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -198,7 +199,7 @@ class TodoListViewModelTests: CoreTestCase { func testDidTapItemOtherTypeWithoutURL() { // Given let todo = TodoItem.make(id: "999", type: .assignment, htmlURL: nil as URL?) - interactor.todosSubject.send([todo]) + interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -212,7 +213,7 @@ class TodoListViewModelTests: CoreTestCase { // When - with non-empty todos interactor.refreshResult = .success(()) - interactor.todosSubject.send([TodoItem.make(id: "1", title: "Test")]) + interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [TodoItem.make(id: "1", title: "Test")])]) testee.refresh(completion: {}, ignoreCache: false) // Then @@ -220,7 +221,7 @@ class TodoListViewModelTests: CoreTestCase { // When - with empty todos interactor.refreshResult = .success(()) - interactor.todosSubject.send([]) + interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [])]) testee.refresh(completion: {}, ignoreCache: false) // Then From cbbe33bb55e562ea24954bf7cb8e99ac7fbb48e4 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 30 Sep 2025 10:45:26 +0200 Subject: [PATCH 10/49] Update layout. --- Core/Core/Features/Todos/View/TodoDayHeaderView.swift | 2 ++ Core/Core/Features/Todos/View/TodoListScreen.swift | 11 +++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift index 731b094485..0fac996cf0 100644 --- a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift +++ b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift @@ -19,6 +19,8 @@ import SwiftUI struct TodoDayHeaderView: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + let group: TodoGroup let tintColor: Color let circleSize: CGFloat diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 3e7aacc25a..59426b6540 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -54,16 +54,19 @@ public struct TodoListScreen: View { onTap: viewModel.didTapItem ) .padding(.vertical, 8) - .padding(.leading, 64) + .padding(.leading, 48) let isLastItemInGroup = (group.items.last == item) - InstUI.Divider().padding(.leading, isLastItemInGroup ? 0 : 64) + InstUI.Divider().padding(.leading, isLastItemInGroup ? 0 : 48) } } header: { TodoDayHeaderView(group: group) - // move day badge to the left + // To provide a large enough hit area, the badge needs to include padding + // but the screen already has a padding so we need to negate that here. + .padding(.leading, -InstUI.Styles.Padding.standard.rawValue) + // Move day badge to the left of the screen. .frame(maxWidth: .infinity, alignment: .leading) - // squeeze height to 0 so day badge goes next to cell + // Squeeze height to 0 so day badge goes next to cell. .frame(height: 0, alignment: .top) } } From 7a1767840aad19e69ff74a16d68173c399f00cb9 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 30 Sep 2025 11:50:42 +0200 Subject: [PATCH 11/49] Route to calendar when tapping on the day header. --- .../Todos/View/TodoDayHeaderView.swift | 10 ++++++---- .../Features/Todos/View/TodoListScreen.swift | 18 ++++++++++-------- .../Todos/ViewModel/TodoListViewModel.swift | 12 ++++++++++++ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift index 0fac996cf0..897043e79f 100644 --- a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift +++ b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift @@ -22,12 +22,14 @@ struct TodoDayHeaderView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize let group: TodoGroup + let onTap: (TodoGroup) -> Void let tintColor: Color let circleSize: CGFloat let circleOpacity: CGFloat - init(group: TodoGroup) { + init(group: TodoGroup, onTap: @escaping (TodoGroup) -> Void) { self.group = group + self.onTap = onTap self.tintColor = group.isToday ? Color.accentColor : .textDark self.circleSize = group.isToday ? 32 : 0 self.circleOpacity = group.isToday ? 1 : 0 @@ -35,7 +37,7 @@ struct TodoDayHeaderView: View { var body: some View { Button { - + onTap(group) } label: { VStack(spacing: 0) { Text(group.weekdayAbbreviation) @@ -72,8 +74,8 @@ struct TodoDayHeaderView: View { ) HStack(spacing: 0) { - TodoDayHeaderView(group: todayGroup) - TodoDayHeaderView(group: tomorrowGroup) + TodoDayHeaderView(group: todayGroup) { _ in } + TodoDayHeaderView(group: tomorrowGroup) { _ in } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.backgroundDarkest) diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 59426b6540..00a378d86d 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -60,14 +60,16 @@ public struct TodoListScreen: View { InstUI.Divider().padding(.leading, isLastItemInGroup ? 0 : 48) } } header: { - TodoDayHeaderView(group: group) - // To provide a large enough hit area, the badge needs to include padding - // but the screen already has a padding so we need to negate that here. - .padding(.leading, -InstUI.Styles.Padding.standard.rawValue) - // Move day badge to the left of the screen. - .frame(maxWidth: .infinity, alignment: .leading) - // Squeeze height to 0 so day badge goes next to cell. - .frame(height: 0, alignment: .top) + TodoDayHeaderView(group: group) { group in + viewModel.didTapDayHeader(group, viewController: viewController) + } + // To provide a large enough hit area, the badge needs to include padding + // but the screen already has a padding so we need to negate that here. + .padding(.leading, -InstUI.Styles.Padding.standard.rawValue) + // Move day badge to the left of the screen. + .frame(maxWidth: .infinity, alignment: .leading) + // Squeeze height to 0 so day badge goes next to cell. + .frame(height: 0, alignment: .top) } } diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index e7a4645765..c1d0f0d2ce 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -19,6 +19,7 @@ import Foundation import Combine import CombineExt +import UIKit public class TodoListViewModel: ObservableObject { @Published var items: [TodoGroup] = [] @@ -71,4 +72,15 @@ public class TodoListViewModel: ObservableObject { func openProfile(_ viewController: WeakViewController) { env.router.route(to: "/profile", from: viewController, options: .modal()) } + + func didTapDayHeader(_ group: TodoGroup, viewController: WeakViewController) { + let tabController = viewController.value.tabBarController + tabController?.selectedIndex = 1 // Switch to Calendar tab + let splitController = tabController?.selectedViewController as? UISplitViewController + splitController?.resetToRoot() + let plannerController = splitController?.masterTopViewController as? PlannerViewController + plannerController?.onAppearOnce { + plannerController?.selectDate(group.date) + } + } } From 7ac197a580d415fba31d58bbcadfdb6b8fd39f97 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 30 Sep 2025 13:54:16 +0200 Subject: [PATCH 12/49] Improve a11y. --- .../Foundation/DateExtensions.swift | 3 ++ .../Core/Features/Todos/Model/TodoGroup.swift | 6 ++++ .../Todos/View/TodoDayHeaderView.swift | 2 ++ .../Features/Todos/Model/TodoGroupTests.swift | 36 +++++++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 Core/CoreTests/Features/Todos/Model/TodoGroupTests.swift diff --git a/Core/Core/Common/Extensions/Foundation/DateExtensions.swift b/Core/Core/Common/Extensions/Foundation/DateExtensions.swift index c42a7e4819..d1755d0b96 100644 --- a/Core/Core/Common/Extensions/Foundation/DateExtensions.swift +++ b/Core/Core/Common/Extensions/Foundation/DateExtensions.swift @@ -299,6 +299,9 @@ public extension Date { Date.dayInMonthFormatter.string(from: self) } + /** + E.g.: 6 + */ var dayString: String { Date.dayFormatter.string(from: self) } diff --git a/Core/Core/Features/Todos/Model/TodoGroup.swift b/Core/Core/Features/Todos/Model/TodoGroup.swift index f4b3a0c896..7fc4adf197 100644 --- a/Core/Core/Features/Todos/Model/TodoGroup.swift +++ b/Core/Core/Features/Todos/Model/TodoGroup.swift @@ -26,6 +26,7 @@ public struct TodoGroup: Identifiable, Equatable { public let dayNumber: String public let isToday: Bool public let displayDate: String + public let accessibilityLabel: String public init(date: Date, items: [TodoItem]) { self.id = date.isoString() @@ -35,5 +36,10 @@ public struct TodoGroup: Identifiable, Equatable { self.dayNumber = date.dayString self.isToday = Cal.currentCalendar.isDateInToday(date) self.displayDate = date.dayInMonth + self.accessibilityLabel = [ + date.weekdayName, + date.dayString, + String.format(numberOfItems: items.count) + ].joined(separator: ", ") } } diff --git a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift index 897043e79f..a9a874c0b8 100644 --- a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift +++ b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift @@ -56,6 +56,8 @@ struct TodoDayHeaderView: View { } .background(Color.backgroundLightest) .buttonStyle(.plain) + .accessibilityLabel(group.accessibilityLabel) + .accessibilityAddTraits(.isHeader) } } diff --git a/Core/CoreTests/Features/Todos/Model/TodoGroupTests.swift b/Core/CoreTests/Features/Todos/Model/TodoGroupTests.swift new file mode 100644 index 0000000000..53e0d9411a --- /dev/null +++ b/Core/CoreTests/Features/Todos/Model/TodoGroupTests.swift @@ -0,0 +1,36 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +@testable import Core +import TestsFoundation +import XCTest + +class TodoGroupTests: CoreTestCase { + + func testAccessibilityLabel() { + let dateComponents = DateComponents(year: 2021, month: 8, day: 7, hour: 12) + let date = Calendar.current.date(from: dateComponents)! + let items = [TodoItem.make(id: "1"), TodoItem.make(id: "2")] + + let group = TodoGroup(date: date, items: items) + + XCTAssertTrue(group.accessibilityLabel.contains("Saturday")) + XCTAssertTrue(group.accessibilityLabel.contains("August 7")) + XCTAssertTrue(group.accessibilityLabel.contains("2 items")) + } +} \ No newline at end of file From 4ee292439dcb2d2dac764e0e6e4fba6a04f1f78d Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 30 Sep 2025 14:21:29 +0200 Subject: [PATCH 13/49] Move entities to the view model level. --- .../Features/Todos/Model/TodoInteractor.swift | 18 ++++++------ .../Todos/View/TodoDayHeaderView.swift | 10 +++---- .../Todos/View/TodoItemContentView.swift | 8 +++--- .../Todos/View/TodoListItemCell.swift | 4 +-- .../Features/Todos/View/TodoListScreen.swift | 2 +- .../TodoGroupViewModel.swift} | 6 ++-- .../TodoItemViewModel.swift} | 14 +++++----- .../Todos/ViewModel/TodoListViewModel.swift | 6 ++-- .../Todos/Model/TodoInteractorMock.swift | 4 +-- .../TodoGroupViewModelTests.swift} | 6 ++-- .../TodoItemViewModelTests.swift} | 6 ++-- .../ViewModel/TodoListViewModelTests.swift | 28 +++++++++---------- 12 files changed, 56 insertions(+), 56 deletions(-) rename Core/Core/Features/Todos/{Model/TodoGroup.swift => ViewModel/TodoGroupViewModel.swift} (90%) rename Core/Core/Features/Todos/{Model/TodoItem.swift => ViewModel/TodoItemViewModel.swift} (95%) rename Core/CoreTests/Features/Todos/{Model/TodoGroupTests.swift => ViewModel/TodoGroupViewModelTests.swift} (86%) rename Core/CoreTests/Features/Todos/{Model/TodoItemTests.swift => ViewModel/TodoItemViewModelTests.swift} (97%) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index ec251aabf4..b390db2242 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -20,16 +20,16 @@ import Foundation import Combine public protocol TodoInteractor { - var todoGroups: AnyPublisher<[TodoGroup], Never> { get } + var todoGroups: AnyPublisher<[TodoGroupViewModel], Never> { get } func refresh(ignoreCache: Bool) -> AnyPublisher } public final class TodoInteractorLive: TodoInteractor { - public var todoGroups: AnyPublisher<[TodoGroup], Never> { + public var todoGroups: AnyPublisher<[TodoGroupViewModel], Never> { todoGroupsSubject.eraseToAnyPublisher() } - private let todoGroupsSubject = CurrentValueSubject<[TodoGroup], Never>([]) + private let todoGroupsSubject = CurrentValueSubject<[TodoGroupViewModel], Never>([]) private let env: AppEnvironment private var subscriptions = Set() @@ -58,9 +58,9 @@ public final class TodoInteractorLive: TodoInteractor { environment: env ) .getEntities(ignoreCache: ignoreCache, loadAllPages: true) - .map { $0.compactMap(TodoItem.init) } + .map { $0.compactMap(TodoItemViewModel.init) } } - .map { [weak todoGroupsSubject] (todos: [TodoItem]) in + .map { [weak todoGroupsSubject] (todos: [TodoItemViewModel]) in TabBarBadgeCounts.todoListCount = UInt(todos.count) // Group todos by day @@ -71,7 +71,7 @@ public final class TodoInteractorLive: TodoInteractor { .eraseToAnyPublisher() } - private static func groupTodosByDay(_ todos: [TodoItem]) -> [TodoGroup] { + private static func groupTodosByDay(_ todos: [TodoItemViewModel]) -> [TodoGroupViewModel] { // Group todos by day using existing Canvas extension let groupedDict = Dictionary(grouping: todos) { todo in todo.date.startOfDay() @@ -79,7 +79,7 @@ public final class TodoInteractorLive: TodoInteractor { // Convert to TodoGroup array and sort by date return groupedDict.map { (date, items) in - TodoGroup(date: date, items: items.sorted { $0.date < $1.date }) + TodoGroupViewModel(date: date, items: items.sorted { $0.date < $1.date }) } .sorted { $0.date < $1.date } } @@ -99,13 +99,13 @@ public final class TodoInteractorPreview: TodoInteractor { let today = Calendar.current.startOfDay(for: Date()) let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today) ?? today - let todayGroup = TodoGroup( + let todayGroup = TodoGroupViewModel( date: today, items: [ .makeShortText(id: "3") ] ) - let tomorrowGroup = TodoGroup( + let tomorrowGroup = TodoGroupViewModel( date: tomorrow, items: [ .makeShortText(id: "1"), diff --git a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift index a9a874c0b8..fe723a10f9 100644 --- a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift +++ b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift @@ -21,13 +21,13 @@ import SwiftUI struct TodoDayHeaderView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize - let group: TodoGroup - let onTap: (TodoGroup) -> Void + let group: TodoGroupViewModel + let onTap: (TodoGroupViewModel) -> Void let tintColor: Color let circleSize: CGFloat let circleOpacity: CGFloat - init(group: TodoGroup, onTap: @escaping (TodoGroup) -> Void) { + init(group: TodoGroupViewModel, onTap: @escaping (TodoGroupViewModel) -> Void) { self.group = group self.onTap = onTap self.tintColor = group.isToday ? Color.accentColor : .textDark @@ -66,11 +66,11 @@ struct TodoDayHeaderView: View { #Preview { let today = Calendar.current.startOfDay(for: Date()) let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() - let todayGroup = TodoGroup( + let todayGroup = TodoGroupViewModel( date: today, items: [.makeShortText(id: "1")] ) - let tomorrowGroup = TodoGroup( + let tomorrowGroup = TodoGroupViewModel( date: tomorrow, items: [.makeShortText(id: "1")] ) diff --git a/Core/Core/Features/Todos/View/TodoItemContentView.swift b/Core/Core/Features/Todos/View/TodoItemContentView.swift index 244fadc3b2..6539dd0fab 100644 --- a/Core/Core/Features/Todos/View/TodoItemContentView.swift +++ b/Core/Core/Features/Todos/View/TodoItemContentView.swift @@ -18,19 +18,19 @@ import SwiftUI -/// A reusable view component that displays the visual content of a TodoItem. +/// A reusable view component that displays the visual content of a TodoItemViewModel. /// This component is used on the Todo List screen and on the Todo widget. public struct TodoItemContentView: View { @ScaledMetric private var uiScale: CGFloat = 1 - public let item: TodoItem + public let item: TodoItemViewModel public let isCompactLayout: Bool /// Initializes a TodoItemContentView /// - Parameters: - /// - item: The TodoItem to display + /// - item: The TodoItemViewModel to display /// - isCompactLayout: If true, text will be limited to single lines with truncation. If false, text can wrap to multiple lines for full display. - public init(item: TodoItem, isCompactLayout: Bool) { + public init(item: TodoItemViewModel, isCompactLayout: Bool) { self.item = item self.isCompactLayout = isCompactLayout } diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index 0f977be1af..3ee0b8b56f 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -22,8 +22,8 @@ struct TodoListItemCell: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.viewController) private var viewController - let item: TodoItem - let onTap: (_ item: TodoItem, _ viewController: WeakViewController) -> Void + let item: TodoItemViewModel + let onTap: (_ item: TodoItemViewModel, _ viewController: WeakViewController) -> Void var body: some View { VStack(spacing: 0) { diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 00a378d86d..da3f526494 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -46,7 +46,7 @@ public struct TodoListScreen: View { } @ViewBuilder - private func groupView(for group: TodoGroup) -> some View { + private func groupView(for group: TodoGroupViewModel) -> some View { Section { ForEach(group.items) { item in TodoListItemCell( diff --git a/Core/Core/Features/Todos/Model/TodoGroup.swift b/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift similarity index 90% rename from Core/Core/Features/Todos/Model/TodoGroup.swift rename to Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift index 7fc4adf197..0230b7d4a1 100644 --- a/Core/Core/Features/Todos/Model/TodoGroup.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift @@ -18,17 +18,17 @@ import Foundation -public struct TodoGroup: Identifiable, Equatable { +public struct TodoGroupViewModel: Identifiable, Equatable { public let id: String public let date: Date - public let items: [TodoItem] + public let items: [TodoItemViewModel] public let weekdayAbbreviation: String public let dayNumber: String public let isToday: Bool public let displayDate: String public let accessibilityLabel: String - public init(date: Date, items: [TodoItem]) { + public init(date: Date, items: [TodoItemViewModel]) { self.id = date.isoString() self.date = date self.items = items diff --git a/Core/Core/Features/Todos/Model/TodoItem.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift similarity index 95% rename from Core/Core/Features/Todos/Model/TodoItem.swift rename to Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index c689e566fb..bd5a23a5a7 100644 --- a/Core/Core/Features/Todos/Model/TodoItem.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -18,7 +18,7 @@ import SwiftUI -public struct TodoItem: Identifiable, Equatable { +public struct TodoItemViewModel: Identifiable, Equatable { public let id: String public let type: PlannableType public let date: Date @@ -86,8 +86,8 @@ public struct TodoItem: Identifiable, Equatable { htmlURL: URL? = nil, color: Color = .red, icon: Image = .assignmentLine - ) -> TodoItem { - TodoItem( + ) -> TodoItemViewModel { + TodoItemViewModel( id: id, type: type, date: date, @@ -110,8 +110,8 @@ public struct TodoItem: Identifiable, Equatable { htmlURL: URL? = nil, color: Color = .blue, icon: Image = .quizLine - ) -> TodoItem { - TodoItem( + ) -> TodoItemViewModel { + TodoItemViewModel( id: id, type: type, date: date, @@ -134,8 +134,8 @@ public struct TodoItem: Identifiable, Equatable { htmlURL: URL? = nil, color: Color = .green, icon: Image = .assignmentLine - ) -> TodoItem { - TodoItem( + ) -> TodoItemViewModel { + TodoItemViewModel( id: id, type: type, date: date, diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index c1d0f0d2ce..e9f85ce762 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -22,7 +22,7 @@ import CombineExt import UIKit public class TodoListViewModel: ObservableObject { - @Published var items: [TodoGroup] = [] + @Published var items: [TodoGroupViewModel] = [] @Published var state: InstUI.ScreenState = .loading private let interactor: TodoInteractor @@ -53,7 +53,7 @@ public class TodoListViewModel: ObservableObject { .store(in: &subscriptions) } - func didTapItem(_ item: TodoItem, _ viewController: WeakViewController) { + func didTapItem(_ item: TodoItemViewModel, _ viewController: WeakViewController) { switch item.type { case .planner_note: let vc = PlannerAssembly.makeToDoDetailsViewController(plannableId: item.id) @@ -73,7 +73,7 @@ public class TodoListViewModel: ObservableObject { env.router.route(to: "/profile", from: viewController, options: .modal()) } - func didTapDayHeader(_ group: TodoGroup, viewController: WeakViewController) { + func didTapDayHeader(_ group: TodoGroupViewModel, viewController: WeakViewController) { let tabController = viewController.value.tabBarController tabController?.selectedIndex = 1 // Switch to Calendar tab let splitController = tabController?.selectedViewController as? UISplitViewController diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift index f6c8e66ecf..379524a16f 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift @@ -20,11 +20,11 @@ import Combine final class TodoInteractorMock: TodoInteractor { - var todoGroups: AnyPublisher<[TodoGroup], Never> { + var todoGroups: AnyPublisher<[TodoGroupViewModel], Never> { todoGroupsSubject.eraseToAnyPublisher() } - let todoGroupsSubject = CurrentValueSubject<[TodoGroup], Never>([]) + let todoGroupsSubject = CurrentValueSubject<[TodoGroupViewModel], Never>([]) var refreshCalled = false var refreshCallCount = 0 var lastIgnoreCache = false diff --git a/Core/CoreTests/Features/Todos/Model/TodoGroupTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift similarity index 86% rename from Core/CoreTests/Features/Todos/Model/TodoGroupTests.swift rename to Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift index 53e0d9411a..528b2877b6 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoGroupTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift @@ -20,14 +20,14 @@ import TestsFoundation import XCTest -class TodoGroupTests: CoreTestCase { +class TodoGroupViewModelTests: CoreTestCase { func testAccessibilityLabel() { let dateComponents = DateComponents(year: 2021, month: 8, day: 7, hour: 12) let date = Calendar.current.date(from: dateComponents)! - let items = [TodoItem.make(id: "1"), TodoItem.make(id: "2")] + let items = [TodoItemViewModel.make(id: "1"), TodoItemViewModel.make(id: "2")] - let group = TodoGroup(date: date, items: items) + let group = TodoGroupViewModel(date: date, items: items) XCTAssertTrue(group.accessibilityLabel.contains("Saturday")) XCTAssertTrue(group.accessibilityLabel.contains("August 7")) diff --git a/Core/CoreTests/Features/Todos/Model/TodoItemTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift similarity index 97% rename from Core/CoreTests/Features/Todos/Model/TodoItemTests.swift rename to Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift index 0122495133..636f67efe5 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoItemTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift @@ -21,15 +21,15 @@ import XCTest import TestsFoundation import SwiftUI -class TodoItemTests: CoreTestCase { +class TodoItemViewModelTests: CoreTestCase { // MARK: - Tests func testInitFromPlannableWithValidDate() { // When let date = Date() - let plannable = makePlannable(contextName: "Test Course", plannableDate: date) - let todoItem = TodoItem(plannable) + let plannable = Plannable.make(id: "test-id", type: .assignment, title: "Test Assignment", contextName: "Test Course", htmlURL: URL(string: "https://example.com"), date: date) + let todoItem = TodoItemViewModel(plannable) // Then XCTAssertNotNil(todoItem) diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift index 61dfb9d40f..d94d79b783 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift @@ -55,10 +55,10 @@ class TodoListViewModelTests: CoreTestCase { func testItemsUpdateFromInteractor() { // Given let testItems = [ - TodoItem.make(id: "1", title: "Test Item 1"), - TodoItem.make(id: "2", title: "Test Item 2") + TodoItemViewModel.make(id: "1", title: "Test Item 1"), + TodoItemViewModel.make(id: "2", title: "Test Item 2") ] - let testGroups = [TodoGroup(date: Date(), items: testItems)] + let testGroups = [TodoGroupViewModel(date: Date(), items: testItems)] // When interactor.todoGroupsSubject.send(testGroups) @@ -108,7 +108,7 @@ class TodoListViewModelTests: CoreTestCase { // Given let expectation = expectation(description: "Refresh completion called") interactor.refreshResult = .success(()) - interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [TodoItem.make(id: "1", title: "Test Item")])]) + interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [TodoItemViewModel.make(id: "1", title: "Test Item")])]) // When testee.refresh(completion: { @@ -153,8 +153,8 @@ class TodoListViewModelTests: CoreTestCase { func testDidTapItemPlannerNote() { // Given - let todo = TodoItem.make(id: "123", type: .planner_note) - interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [todo])]) + let todo = TodoItemViewModel.make(id: "123", type: .planner_note) + interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -166,12 +166,12 @@ class TodoListViewModelTests: CoreTestCase { func testDidTapItemCalendarEvent() { // Given - let todo = TodoItem.make( + let todo = TodoItemViewModel.make( id: "456", type: .calendar_event, htmlURL: URL(string: "https://canvas.instructure.com/calendar") ) - interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [todo])]) + interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -183,11 +183,11 @@ class TodoListViewModelTests: CoreTestCase { func testDidTapItemOtherTypeWithURL() { // Given - let todo = TodoItem.make( + let todo = TodoItemViewModel.make( id: "789", type: .assignment, htmlURL: URL(string: "https://canvas.instructure.com/courses/1/assignments/789")) - interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [todo])]) + interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -198,8 +198,8 @@ class TodoListViewModelTests: CoreTestCase { func testDidTapItemOtherTypeWithoutURL() { // Given - let todo = TodoItem.make(id: "999", type: .assignment, htmlURL: nil as URL?) - interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [todo])]) + let todo = TodoItemViewModel.make(id: "999", type: .assignment, htmlURL: nil as URL?) + interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -213,7 +213,7 @@ class TodoListViewModelTests: CoreTestCase { // When - with non-empty todos interactor.refreshResult = .success(()) - interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [TodoItem.make(id: "1", title: "Test")])]) + interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [TodoItemViewModel.make(id: "1", title: "Test")])]) testee.refresh(completion: {}, ignoreCache: false) // Then @@ -221,7 +221,7 @@ class TodoListViewModelTests: CoreTestCase { // When - with empty todos interactor.refreshResult = .success(()) - interactor.todoGroupsSubject.send([TodoGroup(date: Date(), items: [])]) + interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [])]) testee.refresh(completion: {}, ignoreCache: false) // Then From 3a9c5c1db57d40301701935bd71104a15ff0f2d3 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 30 Sep 2025 14:26:40 +0200 Subject: [PATCH 14/49] Continue renaming. --- .../Features/Todos/Model/TodoInteractor.swift | 4 ++-- .../ViewModel/TodoGroupViewModelTests.swift | 6 +++--- Student/Widgets/Common/Model/Routes.swift | 2 +- .../Controller/TodoWidgetProvider.swift | 2 +- .../Widgets/TodoWidget/Model/TodoList.swift | 2 +- .../Widgets/TodoWidget/Model/TodoModel.swift | 18 +++++++++--------- .../View/Components/TodoItemView.swift | 2 +- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index b390db2242..bda31e0413 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -88,9 +88,9 @@ public final class TodoInteractorLive: TodoInteractor { #if DEBUG public final class TodoInteractorPreview: TodoInteractor { - public let todoGroups: AnyPublisher<[TodoGroup], Never> + public let todoGroups: AnyPublisher<[TodoGroupViewModel], Never> - public init(todoGroups: [TodoGroup] = []) { + public init(todoGroups: [TodoGroupViewModel] = []) { if todoGroups.isNotEmpty { self.todoGroups = Publishers.typedJust(todoGroups) return diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift index 528b2877b6..7a074c7500 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift @@ -26,11 +26,11 @@ class TodoGroupViewModelTests: CoreTestCase { let dateComponents = DateComponents(year: 2021, month: 8, day: 7, hour: 12) let date = Calendar.current.date(from: dateComponents)! let items = [TodoItemViewModel.make(id: "1"), TodoItemViewModel.make(id: "2")] - + let group = TodoGroupViewModel(date: date, items: items) - + XCTAssertTrue(group.accessibilityLabel.contains("Saturday")) XCTAssertTrue(group.accessibilityLabel.contains("August 7")) XCTAssertTrue(group.accessibilityLabel.contains("2 items")) } -} \ No newline at end of file +} diff --git a/Student/Widgets/Common/Model/Routes.swift b/Student/Widgets/Common/Model/Routes.swift index 9cd00329c7..668af60d31 100644 --- a/Student/Widgets/Common/Model/Routes.swift +++ b/Student/Widgets/Common/Model/Routes.swift @@ -31,7 +31,7 @@ extension Assignment { } } -extension TodoItem { +extension TodoItemViewModel { var route: URL { var url = switch type { case .calendar_event: diff --git a/Student/Widgets/TodoWidget/Controller/TodoWidgetProvider.swift b/Student/Widgets/TodoWidget/Controller/TodoWidgetProvider.swift index 56f8ee9d20..bada2716fd 100644 --- a/Student/Widgets/TodoWidget/Controller/TodoWidgetProvider.swift +++ b/Student/Widgets/TodoWidget/Controller/TodoWidgetProvider.swift @@ -105,7 +105,7 @@ class TodoWidgetProvider: TimelineProvider { .filter { $0.plannableType != .announcement && $0.plannableType != .assessment_request } - .compactMap(TodoItem.init) + .compactMap(TodoItemViewModel.init) let model = TodoModel(items: todoItems) let entry = TodoWidgetEntry(data: model, date: Clock.now) diff --git a/Student/Widgets/TodoWidget/Model/TodoList.swift b/Student/Widgets/TodoWidget/Model/TodoList.swift index 1f7ddbdfc7..2e7067ef00 100644 --- a/Student/Widgets/TodoWidget/Model/TodoList.swift +++ b/Student/Widgets/TodoWidget/Model/TodoList.swift @@ -26,6 +26,6 @@ struct TodoList { struct TodoDay: Identifiable { let date: Date - let items: [TodoItem] + let items: [TodoItemViewModel] var id: Double { date.timeIntervalSince1970 } } diff --git a/Student/Widgets/TodoWidget/Model/TodoModel.swift b/Student/Widgets/TodoWidget/Model/TodoModel.swift index c365e55a50..1466d4ac91 100644 --- a/Student/Widgets/TodoWidget/Model/TodoModel.swift +++ b/Student/Widgets/TodoWidget/Model/TodoModel.swift @@ -25,12 +25,12 @@ class TodoModel: WidgetModel { Self.make() } - let items: [TodoItem] + let items: [TodoItemViewModel] let error: TodoError? init( isLoggedIn: Bool = true, - items: [TodoItem] = [], + items: [TodoItemViewModel] = [], error: TodoError? = nil ) { self.items = items @@ -66,7 +66,7 @@ extension TodoModel { static func make(count: Int = 5) -> TodoModel { let items = [ - TodoItem.make( + TodoItemViewModel.make( id: "1", type: .assignment, date: Date.now, title: "Important Assignment", @@ -74,7 +74,7 @@ extension TodoModel { color: .orange, icon: .assignmentLine ), - TodoItem.make( + TodoItemViewModel.make( id: "2", type: .discussion_topic, date: Date.now, @@ -83,7 +83,7 @@ extension TodoModel { color: .orange, icon: .discussionLine ), - TodoItem.make( + TodoItemViewModel.make( id: "3", type: .calendar_event, date: Date.now, @@ -92,10 +92,10 @@ extension TodoModel { color: .orange, icon: .calendarMonthLine ), - TodoItem.make(id: "4", type: .planner_note, date: Date.now.addDays(3), title: "Don't forget", icon: .noteLine), - TodoItem.make(id: "5", type: .quiz, date: Date.now.addDays(3), title: "Quiz About Life", icon: .quizLine), - TodoItem.make(id: "6", type: .assignment, date: Date.now.addDays(3), title: "Another Assignment", icon: .assignmentLine), - TodoItem.make(id: "7", type: .wiki_page, date: Date.now.addDays(3), title: "Some Page", icon: .documentLine) + TodoItemViewModel.make(id: "4", type: .planner_note, date: Date.now.addDays(3), title: "Don't forget", icon: .noteLine), + TodoItemViewModel.make(id: "5", type: .quiz, date: Date.now.addDays(3), title: "Quiz About Life", icon: .quizLine), + TodoItemViewModel.make(id: "6", type: .assignment, date: Date.now.addDays(3), title: "Another Assignment", icon: .assignmentLine), + TodoItemViewModel.make(id: "7", type: .wiki_page, date: Date.now.addDays(3), title: "Some Page", icon: .documentLine) ] return TodoModel(items: Array(items.prefix(count))) } diff --git a/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift b/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift index 3eb3dd9e53..1cc20b6c7f 100644 --- a/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift +++ b/Student/Widgets/TodoWidget/View/Components/TodoItemView.swift @@ -23,7 +23,7 @@ import Core struct TodoItemView: View { @ScaledMetric private var uiScale: CGFloat = 1 - var item: TodoItem + var item: TodoItemViewModel var body: some View { Link(destination: item.route) { From b8d723f2034f0274b9a3fcf3b88ab604a5b283eb Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 30 Sep 2025 14:32:04 +0200 Subject: [PATCH 15/49] Move sorting logic to view model entities. --- Core/Core/Features/Todos/Model/TodoInteractor.swift | 4 ++-- .../Features/Todos/ViewModel/TodoGroupViewModel.swift | 8 +++++++- .../Core/Features/Todos/ViewModel/TodoItemViewModel.swift | 8 +++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index bda31e0413..ae91accc79 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -79,9 +79,9 @@ public final class TodoInteractorLive: TodoInteractor { // Convert to TodoGroup array and sort by date return groupedDict.map { (date, items) in - TodoGroupViewModel(date: date, items: items.sorted { $0.date < $1.date }) + TodoGroupViewModel(date: date, items: items.sorted()) } - .sorted { $0.date < $1.date } + .sorted() } } diff --git a/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift index 0230b7d4a1..2b6fb4d1f0 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift @@ -18,7 +18,7 @@ import Foundation -public struct TodoGroupViewModel: Identifiable, Equatable { +public struct TodoGroupViewModel: Identifiable, Equatable, Comparable { public let id: String public let date: Date public let items: [TodoItemViewModel] @@ -42,4 +42,10 @@ public struct TodoGroupViewModel: Identifiable, Equatable { String.format(numberOfItems: items.count) ].joined(separator: ", ") } + + // MARK: - Comparable + + public static func < (lhs: TodoGroupViewModel, rhs: TodoGroupViewModel) -> Bool { + lhs.date < rhs.date + } } diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index bd5a23a5a7..23d8d27ffe 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -18,7 +18,7 @@ import SwiftUI -public struct TodoItemViewModel: Identifiable, Equatable { +public struct TodoItemViewModel: Identifiable, Equatable, Comparable { public let id: String public let type: PlannableType public let date: Date @@ -72,6 +72,12 @@ public struct TodoItemViewModel: Identifiable, Equatable { self.icon = icon } + // MARK: - Comparable + + public static func < (lhs: TodoItemViewModel, rhs: TodoItemViewModel) -> Bool { + lhs.date < rhs.date + } + // MARK: Preview & Testing #if DEBUG From 74c390b75db30d1b3271df2c8d17a81643d11807 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 30 Sep 2025 15:30:01 +0200 Subject: [PATCH 16/49] Update unit tests. --- .../Todos/ViewModel/TodoItemViewModel.swift | 1 - .../ViewModel/TodoGroupViewModelTests.swift | 39 ++++++++++++++- .../ViewModel/TodoItemViewModelTests.swift | 47 ++++++++----------- .../ViewModel/TodoListViewModelTests.swift | 14 +++++- 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index 23d8d27ffe..1a054bb527 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -58,7 +58,6 @@ public struct TodoItemViewModel: Identifiable, Equatable, Comparable { color: Color, icon: Image ) { - self.id = id self.type = type self.date = date diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift index 7a074c7500..3668c7ac29 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift @@ -30,7 +30,44 @@ class TodoGroupViewModelTests: CoreTestCase { let group = TodoGroupViewModel(date: date, items: items) XCTAssertTrue(group.accessibilityLabel.contains("Saturday")) - XCTAssertTrue(group.accessibilityLabel.contains("August 7")) + XCTAssertTrue(group.accessibilityLabel.contains("7")) XCTAssertTrue(group.accessibilityLabel.contains("2 items")) } + + func testDateFormatting() { + let dateComponents = DateComponents(year: 2021, month: 12, day: 25, hour: 15) + let date = Calendar.current.date(from: dateComponents)! + let items = [TodoItemViewModel.make(id: "1")] + + let group = TodoGroupViewModel(date: date, items: items) + + XCTAssertEqual(group.id, date.isoString()) + XCTAssertEqual(group.date, date) + XCTAssertEqual(group.weekdayAbbreviation, date.weekdayNameAbbreviated) + XCTAssertEqual(group.dayNumber, date.dayString) + XCTAssertEqual(group.displayDate, date.dayInMonth) + } + + func testIsToday() { + let today = Date() + let yesterday = today.addDays(-1) + + let todayGroup = TodoGroupViewModel(date: today, items: []) + let yesterdayGroup = TodoGroupViewModel(date: yesterday, items: []) + + XCTAssertTrue(todayGroup.isToday) + XCTAssertFalse(yesterdayGroup.isToday) + } + + func testComparison() { + let date1 = Date.make(year: 2021, month: 1, day: 1) + let date2 = Date.make(year: 2021, month: 1, day: 2) + let items = [TodoItemViewModel.make(id: "1")] + + let group1 = TodoGroupViewModel(date: date1, items: items) + let group2 = TodoGroupViewModel(date: date2, items: items) + + XCTAssertTrue(group1 < group2) + XCTAssertFalse(group2 < group1) + } } diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift index 636f67efe5..7b4cff3c5d 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift @@ -28,7 +28,14 @@ class TodoItemViewModelTests: CoreTestCase { func testInitFromPlannableWithValidDate() { // When let date = Date() - let plannable = Plannable.make(id: "test-id", type: .assignment, title: "Test Assignment", contextName: "Test Course", htmlURL: URL(string: "https://example.com"), date: date) + let plannable = makePlannable( + plannableId: "test-id", + plannableType: "assignment", + htmlURL: URL(string: "https://example.com")!, + contextName: "Test Course", + plannable: .make(title: "Test Assignment"), + plannableDate: date + ) let todoItem = TodoItemViewModel(plannable) // Then @@ -45,7 +52,7 @@ class TodoItemViewModelTests: CoreTestCase { func testInitFromPlannableWithEmptyTitle() { // When let plannable = makePlannable(plannable: .make(title: nil)) - let todoItem = TodoItem(plannable) + let todoItem = TodoItemViewModel(plannable) // Then XCTAssertNotNil(todoItem) @@ -63,7 +70,7 @@ class TodoItemViewModelTests: CoreTestCase { details: .make(reply_to_entry_required_count: nil) ) plannable.discussionCheckpointStep = .replyToTopic - let todoItem = TodoItem(plannable) + let todoItem = TodoItemViewModel(plannable) // Then XCTAssertNotNil(todoItem) @@ -81,7 +88,7 @@ class TodoItemViewModelTests: CoreTestCase { details: .make(reply_to_entry_required_count: 3) ) plannable.discussionCheckpointStep = .requiredReplies(3) - let todoItem = TodoItem(plannable) + let todoItem = TodoItemViewModel(plannable) // Then XCTAssertNotNil(todoItem) @@ -95,7 +102,7 @@ class TodoItemViewModelTests: CoreTestCase { contextName: "Math 101", plannable: .make(title: "Study for exam") ) - let todoItem = TodoItem(plannable) + let todoItem = TodoItemViewModel(plannable) // Then XCTAssertNotNil(todoItem) @@ -110,7 +117,7 @@ class TodoItemViewModelTests: CoreTestCase { contextName: nil, plannable: .make(title: "Personal note") ) - let todoItem = TodoItem(plannable) + let todoItem = TodoItemViewModel(plannable) // Then XCTAssertNotNil(todoItem) @@ -121,7 +128,7 @@ class TodoItemViewModelTests: CoreTestCase { // When let date = Date() let url = URL(string: "https://example.com")! - let todoItem = TodoItem( + let todoItem = TodoItemViewModel( id: "direct-id", type: .quiz, date: date, @@ -148,7 +155,7 @@ class TodoItemViewModelTests: CoreTestCase { // When let date = Date() let url = URL(string: "https://example.com")! - let todoItem = TodoItem.make( + let todoItem = TodoItemViewModel.make( id: "factory-id", type: .discussion_topic, date: date, @@ -171,25 +178,11 @@ class TodoItemViewModelTests: CoreTestCase { XCTAssertEqual(todoItem.color, .green) } - func testMakeFactoryMethodWithDefaults() { - // When - let todoItem = TodoItem.make() - - // Then - XCTAssertEqual(todoItem.id, "") - XCTAssertEqual(todoItem.type, .assignment) - XCTAssertEqual(todoItem.title, "") - XCTAssertNil(todoItem.subtitle) - XCTAssertEqual(todoItem.contextName, "") - XCTAssertNil(todoItem.htmlURL) - XCTAssertEqual(todoItem.color, .red) - } - func testEquality() { // When let date = Date() let url = URL(string: "https://example.com")! - let todoItem1 = TodoItem( + let todoItem1 = TodoItemViewModel( id: "same-id", type: .assignment, date: date, @@ -201,7 +194,7 @@ class TodoItemViewModelTests: CoreTestCase { icon: .assignmentLine ) - let todoItem2 = TodoItem( + let todoItem2 = TodoItemViewModel( id: "same-id", type: .assignment, date: date, @@ -213,7 +206,7 @@ class TodoItemViewModelTests: CoreTestCase { icon: .assignmentLine ) - let todoItem3 = TodoItem( + let todoItem3 = TodoItemViewModel( id: "different-id", type: .assignment, date: date, @@ -247,7 +240,7 @@ class TodoItemViewModelTests: CoreTestCase { for type in allTypes { // When - let todoItem = TodoItem(makePlannable(plannableType: type.rawValue)) + let todoItem = TodoItemViewModel(makePlannable(plannableType: type.rawValue)) // Then XCTAssertNotNil(todoItem) @@ -274,7 +267,7 @@ class TodoItemViewModelTests: CoreTestCase { plannable: plannable, plannableDate: plannableDate, details: details - )) + ), in: databaseClient) } private func makeApiPlannable( diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift index d94d79b783..80c4811133 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift @@ -221,7 +221,7 @@ class TodoListViewModelTests: CoreTestCase { // When - with empty todos interactor.refreshResult = .success(()) - interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [])]) + interactor.todoGroupsSubject.send([]) testee.refresh(completion: {}, ignoreCache: false) // Then @@ -248,4 +248,16 @@ class TodoListViewModelTests: CoreTestCase { // Then XCTAssertEqual(interactor.refreshCallCount, 3) } + + func testOpenProfile() { + // Given + let viewController = WeakViewController() + + // When + testee.openProfile(viewController) + + // Then + XCTAssert(router.lastRoutedTo("/profile")) + XCTAssertEqual(router.calls.last?.2.isModal, true) + } } From 6bed69e82a5063beae9c439189086811652d02fc Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 30 Sep 2025 17:13:09 +0200 Subject: [PATCH 17/49] Update layout. --- .../Todos/View/TodoDayHeaderView.swift | 20 +++++++++---------- .../Todos/View/TodoItemContentView.swift | 11 +++++----- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift index fe723a10f9..2981aa9067 100644 --- a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift +++ b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift @@ -24,15 +24,11 @@ struct TodoDayHeaderView: View { let group: TodoGroupViewModel let onTap: (TodoGroupViewModel) -> Void let tintColor: Color - let circleSize: CGFloat - let circleOpacity: CGFloat init(group: TodoGroupViewModel, onTap: @escaping (TodoGroupViewModel) -> Void) { self.group = group self.onTap = onTap self.tintColor = group.isToday ? Color.accentColor : .textDark - self.circleSize = group.isToday ? 32 : 0 - self.circleOpacity = group.isToday ? 1 : 0 } var body: some View { @@ -42,15 +38,19 @@ struct TodoDayHeaderView: View { VStack(spacing: 0) { Text(group.weekdayAbbreviation) .font(.regular12, lineHeight: .fit) - Text(group.dayNumber) - .font(group.isToday ? .bold12 : .regular12, lineHeight: .fit) - .frame(minWidth: circleSize, minHeight: circleSize) - .background(Circle().stroke(tintColor).opacity(circleOpacity)) - .padding(.top, group.isToday ? 0 : -2) + ZStack { + Circle() + .stroke(tintColor) + .frame(width: 32, height: 32) + .hidden(!group.isToday) + Text(group.dayNumber) + .font(group.isToday ? .bold12 : .regular12, lineHeight: .fit) + .padding(.top, group.isToday ? 0 : -14) + } } .padding(.top, 8) + .padding(.bottom, 9) // to maximize hit area .frame(width: 64, alignment: .center) - .frame(minHeight: 48, alignment: .top) .foregroundStyle(tintColor) .contentShape(Rectangle()) } diff --git a/Core/Core/Features/Todos/View/TodoItemContentView.swift b/Core/Core/Features/Todos/View/TodoItemContentView.swift index 6539dd0fab..0dc16f5379 100644 --- a/Core/Core/Features/Todos/View/TodoItemContentView.swift +++ b/Core/Core/Features/Todos/View/TodoItemContentView.swift @@ -29,7 +29,7 @@ public struct TodoItemContentView: View { /// Initializes a TodoItemContentView /// - Parameters: /// - item: The TodoItemViewModel to display - /// - isCompactLayout: If true, text will be limited to single lines with truncation. If false, text can wrap to multiple lines for full display. + /// - isCompactLayout: If true, text will be limited to single lines with truncation and font sizes become smaller for better widget presentation. public init(item: TodoItemViewModel, isCompactLayout: Bool) { self.item = item self.isCompactLayout = isCompactLayout @@ -52,10 +52,11 @@ public struct TodoItemContentView: View { .foregroundStyle(item.color) .accessibilityHidden(true) .frame(maxHeight: .infinity, alignment: .top) + .padding(.top, isCompactLayout ? 0 : 2) InstUI.Divider() Text(item.contextName) .foregroundStyle(item.color) - .font(.regular12, lineHeight: .fit) + .font(isCompactLayout ? .regular12 : .regular14, lineHeight: .fit) .lineLimit(isCompactLayout ? 1 : nil) } .fixedSize(horizontal: false, vertical: true) @@ -64,12 +65,12 @@ public struct TodoItemContentView: View { private var titleSection: some View { VStack(alignment: .leading) { Text(item.title) - .font(.semibold14, lineHeight: .fit) + .font(isCompactLayout ? .semibold14 : .regular16, lineHeight: .fit) .foregroundStyle(.textDarkest) .lineLimit(isCompactLayout ? 1 : nil) if let subtitle = item.subtitle { Text(subtitle) - .font(.regular12, lineHeight: .fit) + .font(isCompactLayout ? .regular12 : .regular14, lineHeight: .fit) .foregroundStyle(.textDark) .lineLimit(isCompactLayout ? 1 : nil) } @@ -78,7 +79,7 @@ public struct TodoItemContentView: View { private var timeSection: some View { Text(item.date.dateTimeStringShort) - .font(.regular12) + .font(isCompactLayout ? .regular12 : .regular14) .foregroundStyle(.textDark) .lineLimit(isCompactLayout ? 1 : nil) .frame(maxWidth: .infinity, alignment: .leading) From e45f352afb7caf33cdd2ec312b368100842c2aba Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 30 Sep 2025 17:28:39 +0200 Subject: [PATCH 18/49] Display only time refs: MBL-19373 builds: Student affects: Student release note: none test plan: - Compare design with figma. - Check accessibility. - Validate proper grouping by dates. --- .../Todos/View/TodoItemContentView.swift | 2 +- .../Todos/ViewModel/TodoItemViewModel.swift | 3 +++ .../ViewModel/TodoItemViewModelTests.swift | 24 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Core/Core/Features/Todos/View/TodoItemContentView.swift b/Core/Core/Features/Todos/View/TodoItemContentView.swift index 0dc16f5379..d42dbd6c6b 100644 --- a/Core/Core/Features/Todos/View/TodoItemContentView.swift +++ b/Core/Core/Features/Todos/View/TodoItemContentView.swift @@ -78,7 +78,7 @@ public struct TodoItemContentView: View { } private var timeSection: some View { - Text(item.date.dateTimeStringShort) + Text(item.dateText) .font(isCompactLayout ? .regular12 : .regular14) .foregroundStyle(.textDark) .lineLimit(isCompactLayout ? 1 : nil) diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index 1a054bb527..7ac1292284 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -22,6 +22,7 @@ public struct TodoItemViewModel: Identifiable, Equatable, Comparable { public let id: String public let type: PlannableType public let date: Date + public let dateText: String public let title: String public let subtitle: String? @@ -37,6 +38,7 @@ public struct TodoItemViewModel: Identifiable, Equatable, Comparable { self.id = plannable.id self.type = plannable.plannableType self.date = date + self.dateText = date.timeOnlyString self.title = plannable.title ?? "" self.subtitle = plannable.discussionCheckpointStep?.text @@ -61,6 +63,7 @@ public struct TodoItemViewModel: Identifiable, Equatable, Comparable { self.id = id self.type = type self.date = date + self.dateText = date.timeOnlyString self.title = title self.subtitle = subtitle diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift index 7b4cff3c5d..dcfe214f45 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift @@ -43,6 +43,7 @@ class TodoItemViewModelTests: CoreTestCase { XCTAssertEqual(todoItem?.id, "test-id") XCTAssertEqual(todoItem?.type, .assignment) XCTAssertEqual(todoItem?.date, date) + XCTAssertEqual(todoItem?.dateText, date.timeOnlyString) XCTAssertEqual(todoItem?.title, "Test Assignment") XCTAssertNil(todoItem?.subtitle) XCTAssertEqual(todoItem?.contextName, "Test Course") @@ -144,6 +145,7 @@ class TodoItemViewModelTests: CoreTestCase { XCTAssertEqual(todoItem.id, "direct-id") XCTAssertEqual(todoItem.type, .quiz) XCTAssertEqual(todoItem.date, date) + XCTAssertEqual(todoItem.dateText, date.timeOnlyString) XCTAssertEqual(todoItem.title, "Direct Quiz") XCTAssertEqual(todoItem.subtitle, "Test subtitle") XCTAssertEqual(todoItem.contextName, "Direct Course") @@ -248,6 +250,28 @@ class TodoItemViewModelTests: CoreTestCase { } } + func testDateTextProperty() { + // Given + let specificDate = Date.make(year: 2025, month: 9, day: 30, hour: 14, minute: 30) + + // When + let todoItem = TodoItemViewModel( + id: "datetest-id", + type: .assignment, + date: specificDate, + title: "Date Test Assignment", + subtitle: nil, + contextName: "Test Course", + htmlURL: nil, + color: .blue, + icon: .assignmentLine + ) + + // Then + XCTAssertEqual(todoItem.dateText, specificDate.timeOnlyString) + XCTAssertEqual(todoItem.date, specificDate) + } + // MARK: - Helpers private func makePlannable( From 474f0c4b311c979f76d1510f230537af8e920e48 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 30 Sep 2025 18:17:36 +0200 Subject: [PATCH 19/49] Update empty state. --- .../SwiftUIViews/Pandas/PandaGallery.swift | 9 +++ .../Pandas/Scenes/VacationPanda.swift | 71 +++++++++++++++++++ .../Features/Todos/Model/TodoInteractor.swift | 4 +- .../Features/Todos/View/TodoListScreen.swift | 8 ++- .../Todos/ViewModel/TodoListViewModel.swift | 7 ++ Core/Core/Resources/Localizable.xcstrings | 6 ++ 6 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 Core/Core/Common/CommonUI/SwiftUIViews/Pandas/Scenes/VacationPanda.swift diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/Pandas/PandaGallery.swift b/Core/Core/Common/CommonUI/SwiftUIViews/Pandas/PandaGallery.swift index f44262c5d3..0b9f3febe5 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/Pandas/PandaGallery.swift +++ b/Core/Core/Common/CommonUI/SwiftUIViews/Pandas/PandaGallery.swift @@ -32,6 +32,9 @@ public struct PandaGallery: View { case conferences case pages case success + case horizonError + case noResults + case vacation } @State private var selectedPanda: PandaType = PandaType.allCases.last! @@ -76,6 +79,12 @@ public struct PandaGallery: View { scene = PagesPanda() case .success: scene = SuccessPanda() + case .horizonError: + scene = HorizonPanda() + case .noResults: + scene = NoResultsPanda() + case .vacation: + scene = VacationPanda() } return InteractivePanda(scene: scene, title: Text(verbatim: "Title Text"), subtitle: Text(verbatim: "Optional subtitle text here")) diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/Pandas/Scenes/VacationPanda.swift b/Core/Core/Common/CommonUI/SwiftUIViews/Pandas/Scenes/VacationPanda.swift new file mode 100644 index 0000000000..48eb094b82 --- /dev/null +++ b/Core/Core/Common/CommonUI/SwiftUIViews/Pandas/Scenes/VacationPanda.swift @@ -0,0 +1,71 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import SwiftUI + +public struct VacationPanda: PandaScene { + public var name: String { "vacation" } + public var foreground: AnyView { AnyView(HammockPanda(imageName: "PandaNoEvents")) } + public var background: AnyView { AnyView(SwiftUI.EmptyView()) } + public var isParallaxDisabled: Bool { false } + + public var offset: (background: CGSize, foreground: CGSize) {( + background: CGSize(width: 0, height: 0), + foreground: CGSize(width: 0, height: 0)) + } + public var height: CGFloat { 168 } + + public init() {} +} + +private struct HammockPanda: View { + @State private var swayOffset: CGFloat = 0 + @State private var swayAngle: Double = 0 + private let imageName: String + + public init(imageName: String) { + self.imageName = imageName + } + + @ViewBuilder + public var body: some View { + Image(imageName, bundle: .core) + .offset(x: swayOffset) + .rotationEffect(.degrees(swayAngle)) + .animation( + .easeInOut(duration: 2.5) + .repeatForever(autoreverses: true), + value: swayOffset + ) + .animation( + .easeInOut(duration: 2.5) + .repeatForever(autoreverses: true), + value: swayAngle + ) + .onAppear { + swayOffset = 8.0 // Gentle side-to-side movement + swayAngle = 2.0 // Slight rotation as hammock rocks + } + } +} + +struct VacationPanda_Previews: PreviewProvider { + static var previews: some View { + InteractivePanda(scene: VacationPanda()) + } +} diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index ae91accc79..406e2b1f11 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -90,8 +90,8 @@ public final class TodoInteractorLive: TodoInteractor { public final class TodoInteractorPreview: TodoInteractor { public let todoGroups: AnyPublisher<[TodoGroupViewModel], Never> - public init(todoGroups: [TodoGroupViewModel] = []) { - if todoGroups.isNotEmpty { + public init(todoGroups: [TodoGroupViewModel]? = nil) { + if let todoGroups { self.todoGroups = Publishers.typedJust(todoGroups) return } diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index da3f526494..149d746628 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -30,6 +30,7 @@ public struct TodoListScreen: View { public var body: some View { InstUI.BaseScreen( state: viewModel.state, + config: viewModel.screenConfig, refreshAction: { completion in viewModel.refresh(completion: completion, ignoreCache: true) } @@ -63,7 +64,7 @@ public struct TodoListScreen: View { TodoDayHeaderView(group: group) { group in viewModel.didTapDayHeader(group, viewController: viewController) } - // To provide a large enough hit area, the badge needs to include padding + // To provide a large enough hit area, the header needs to include padding // but the screen already has a padding so we need to negate that here. .padding(.leading, -InstUI.Styles.Padding.standard.rawValue) // Move day badge to the left of the screen. @@ -98,4 +99,9 @@ public struct TodoListScreen: View { TodoListScreen(viewModel: viewModel) } +#Preview("Empty State") { + let viewModel = TodoListViewModel(interactor: TodoInteractorPreview(todoGroups: []), env: PreviewEnvironment()) + TodoListScreen(viewModel: viewModel) +} + #endif diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index e9f85ce762..218ad3222d 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -24,6 +24,13 @@ import UIKit public class TodoListViewModel: ObservableObject { @Published var items: [TodoGroupViewModel] = [] @Published var state: InstUI.ScreenState = .loading + let screenConfig = InstUI.BaseScreenConfig( + emptyPandaConfig: .init( + scene: VacationPanda(), + title: String(localized: "No To Dos for now!", bundle: .core), + subtitle: String(localized: "It looks like a great time to rest, relax, and recharge.", bundle: .core) + ) + ) private let interactor: TodoInteractor private let env: AppEnvironment diff --git a/Core/Core/Resources/Localizable.xcstrings b/Core/Core/Resources/Localizable.xcstrings index 7a3b122b98..ce94f9eb7c 100644 --- a/Core/Core/Resources/Localizable.xcstrings +++ b/Core/Core/Resources/Localizable.xcstrings @@ -194687,6 +194687,9 @@ } } } + }, + "It looks like a great time to rest, relax, and recharge." : { + }, "It looks like announcements haven’t been created in this space yet." : { "localizations" : { @@ -238678,6 +238681,9 @@ } } } + }, + "No To Dos for now!" : { + }, "None" : { "localizations" : { From d63b8c6485d7b484ca7574b69bd8107037ec02f1 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 1 Oct 2025 10:08:10 +0200 Subject: [PATCH 20/49] Update course name lookup logic. --- Core/Core/Features/Courses/APICourse.swift | 3 ++ Core/Core/Features/Courses/Course.swift | 7 ++++ .../Features/Todos/Model/TodoInteractor.swift | 19 +++++++---- .../Todos/ViewModel/TodoItemViewModel.swift | 32 +++++++++++++++++-- .../Database.xcdatamodel/contents | 3 +- .../Features/Courses/CourseTests.swift | 25 +++++++++++++++ .../ViewModel/TodoItemViewModelTests.swift | 4 +-- .../Controller/TodoWidgetProvider.swift | 2 +- 8 files changed, 83 insertions(+), 12 deletions(-) diff --git a/Core/Core/Features/Courses/APICourse.swift b/Core/Core/Features/Courses/APICourse.swift index c0f66e0370..5e1a7119f0 100644 --- a/Core/Core/Features/Courses/APICourse.swift +++ b/Core/Core/Features/Courses/APICourse.swift @@ -26,6 +26,7 @@ public struct APICourse: Codable, Equatable { // let integration_id: String? // let sis_import_id: String? let name: String? + let original_name: String? let course_code: String? /** Teacher assigned course color for K5 in hex format. */ let course_color: String? @@ -140,6 +141,7 @@ extension APICourse { public static func make( id: ID = "1", name: String? = "Course One", + original_name: String? = nil, course_code: String? = "C1", course_color: String? = nil, workflow_state: CourseWorkflowState? = nil, @@ -176,6 +178,7 @@ extension APICourse { return APICourse( id: id, name: name, + original_name: original_name, course_code: course_code, course_color: course_color, workflow_state: workflow_state, diff --git a/Core/Core/Features/Courses/Course.swift b/Core/Core/Features/Courses/Course.swift index 7007f4e1cc..743e272007 100644 --- a/Core/Core/Features/Courses/Course.swift +++ b/Core/Core/Features/Courses/Course.swift @@ -45,6 +45,8 @@ final public class Course: NSManagedObject, WriteableModel { @NSManaged public var isPastEnrollment: Bool @NSManaged public var isPublished: Bool @NSManaged public var name: String? + /** If `name` property contains the user given nickname, then this field contains the original teacher associated name. Nil otherwise. */ + @NSManaged public var originalName: String? @NSManaged public var sections: Set @NSManaged public var syllabusBody: String? @NSManaged public var termName: String? @@ -77,6 +79,10 @@ final public class Course: NSManagedObject, WriteableModel { Context(.course, id: id).canvasContextID } + public var hasNickName: Bool { + originalName != nil + } + public var color: UIColor { if AppEnvironment.shared.k5.isK5Enabled { return UIColor(hexString: courseColor)?.ensureContrast(against: .backgroundLightest) ?? .textDarkest @@ -90,6 +96,7 @@ final public class Course: NSManagedObject, WriteableModel { let model: Course = context.first(where: #keyPath(Course.id), equals: item.id.value) ?? context.insert() model.id = item.id.value model.name = item.name + model.originalName = item.original_name model.isFavorite = item.is_favorite ?? false model.courseCode = item.course_code model.courseColor = item.course_color diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index 406e2b1f11..7a5f6b3061 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -45,20 +45,27 @@ public final class TodoInteractorLive: TodoInteractor { return ReactiveStore(useCase: GetCourses(), environment: env) .getEntities(ignoreCache: ignoreCache) - .map { - var contextCodes: [String] = $0.filter(\.isPublished).map(\.canvasContextID) + .map { courses in + var contextCodes: [String] = courses.filter(\.isPublished).map(\.canvasContextID) if let userContextCode = Context(.user, id: currentUserID)?.canvasContextID { contextCodes.append(userContextCode) } - return contextCodes + return (contextCodes, courses) } - .flatMap { [env] codes in + .flatMap { [env] (courseContextCodes, courses: [Course]) in ReactiveStore( - useCase: GetPlannables(startDate: startDate, endDate: endDate, contextCodes: codes), + useCase: GetPlannables(startDate: startDate, endDate: endDate, contextCodes: courseContextCodes), environment: env ) .getEntities(ignoreCache: ignoreCache, loadAllPages: true) - .map { $0.compactMap(TodoItemViewModel.init) } + .map { plannables in + return plannables.compactMap { plannable in + let course = courses.first { course in + course.canvasContextID == plannable.canvasContextIDRaw + } + return TodoItemViewModel(plannable, course: course) + } + } } .map { [weak todoGroupsSubject] (todos: [TodoItemViewModel]) in TabBarBadgeCounts.todoListCount = UInt(todos.count) diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index 7ac1292284..aa20b89a81 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -32,7 +32,7 @@ public struct TodoItemViewModel: Identifiable, Equatable, Comparable { public let color: Color public let icon: Image - public init?(_ plannable: Plannable) { + public init?(_ plannable: Plannable, course: Course? = nil) { guard let date = plannable.date else { return nil } self.id = plannable.id @@ -42,13 +42,41 @@ public struct TodoItemViewModel: Identifiable, Equatable, Comparable { self.title = plannable.title ?? "" self.subtitle = plannable.discussionCheckpointStep?.text - self.contextName = plannable.contextNameUserFacing ?? "" + + // Use course-based contextName if course is provided, otherwise use plannable context + self.contextName = Self.contextName( + isCourseNameNickname: course?.hasNickName ?? false, + courseName: course?.name, + courseCode: course?.courseCode, + fallback: plannable.contextNameUserFacing ?? "" + ) + self.htmlURL = plannable.htmlURL self.color = plannable.color.asColor self.icon = Image(uiImage: plannable.icon) } + /// Helper function to determine the context name for a Todo item. + /// - Parameters: + /// - isCourseNameNickname: Whether the course name is a user-given nickname. + /// - courseName: The course name (which may be a nickname if isCourseNameNickname is true). + /// - courseCode: The course code. + /// - fallback: Fallback value if no course data is available. + /// - Returns: The appropriate context name. + public static func contextName( + isCourseNameNickname: Bool, + courseName: String?, + courseCode: String?, + fallback: String = "" + ) -> String { + if isCourseNameNickname { + return courseName ?? fallback + } else { + return courseCode ?? courseName ?? fallback + } + } + public init( id: String, type: PlannableType, diff --git a/Core/Core/Resources/Database.xcdatamodeld/Database.xcdatamodel/contents b/Core/Core/Resources/Database.xcdatamodeld/Database.xcdatamodel/contents index 1045b6a7af..68754a8696 100644 --- a/Core/Core/Resources/Database.xcdatamodeld/Database.xcdatamodel/contents +++ b/Core/Core/Resources/Database.xcdatamodeld/Database.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -623,6 +623,7 @@ + diff --git a/Core/CoreTests/Features/Courses/CourseTests.swift b/Core/CoreTests/Features/Courses/CourseTests.swift index 44632fb6f0..50ce84fe18 100644 --- a/Core/CoreTests/Features/Courses/CourseTests.swift +++ b/Core/CoreTests/Features/Courses/CourseTests.swift @@ -309,4 +309,29 @@ class CourseTests: CoreTestCase { tabs7 = tabsForCourseId("7") XCTAssertEqual(tabs7.contains { $0.id == "grades" }, true) } + + func testOriginalNameMapping() { + let originalName = "Original Course Name" + let apiCourse = APICourse.make(id: "1", original_name: originalName) + let course = Course.make(from: apiCourse) + + XCTAssertEqual(course.originalName, originalName) + + let apiCourseWithoutOriginalName = APICourse.make(id: "2", original_name: nil) + let courseWithoutOriginalName = Course.make(from: apiCourseWithoutOriginalName) + + XCTAssertNil(courseWithoutOriginalName.originalName) + } + + func testHasNickName() { + let apiCourseWithOriginalName = APICourse.make(id: "1", original_name: "Original Name") + let courseWithNickName = Course.make(from: apiCourseWithOriginalName) + + XCTAssertTrue(courseWithNickName.hasNickName) + + let apiCourseWithoutOriginalName = APICourse.make(id: "2", original_name: nil) + let courseWithoutNickName = Course.make(from: apiCourseWithoutOriginalName) + + XCTAssertFalse(courseWithoutNickName.hasNickName) + } } diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift index dcfe214f45..dd4fdb5071 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift @@ -253,7 +253,7 @@ class TodoItemViewModelTests: CoreTestCase { func testDateTextProperty() { // Given let specificDate = Date.make(year: 2025, month: 9, day: 30, hour: 14, minute: 30) - + // When let todoItem = TodoItemViewModel( id: "datetest-id", @@ -266,7 +266,7 @@ class TodoItemViewModelTests: CoreTestCase { color: .blue, icon: .assignmentLine ) - + // Then XCTAssertEqual(todoItem.dateText, specificDate.timeOnlyString) XCTAssertEqual(todoItem.date, specificDate) diff --git a/Student/Widgets/TodoWidget/Controller/TodoWidgetProvider.swift b/Student/Widgets/TodoWidget/Controller/TodoWidgetProvider.swift index bada2716fd..72e1cfa09e 100644 --- a/Student/Widgets/TodoWidget/Controller/TodoWidgetProvider.swift +++ b/Student/Widgets/TodoWidget/Controller/TodoWidgetProvider.swift @@ -105,7 +105,7 @@ class TodoWidgetProvider: TimelineProvider { .filter { $0.plannableType != .announcement && $0.plannableType != .assessment_request } - .compactMap(TodoItemViewModel.init) + .compactMap { (TodoItemViewModel($0)) } let model = TodoModel(items: todoItems) let entry = TodoWidgetEntry(data: model, date: Clock.now) From 465b4b66e0502d3c184f6053f1b4cc4f212bc093 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 1 Oct 2025 12:52:28 +0200 Subject: [PATCH 21/49] Update public previews. --- .../Widgets/Resources/Localizable.xcstrings | 39 +++++++++ .../Widgets/TodoWidget/Model/TodoModel.swift | 83 +++++++++++++++---- 2 files changed, 104 insertions(+), 18 deletions(-) diff --git a/Student/Widgets/Resources/Localizable.xcstrings b/Student/Widgets/Resources/Localizable.xcstrings index 0b454f19c4..b32cc5e5dd 100644 --- a/Student/Widgets/Resources/Localizable.xcstrings +++ b/Student/Widgets/Resources/Localizable.xcstrings @@ -1799,6 +1799,9 @@ } } } + }, + "Biology 101" : { + }, "Biology 201" : { "comment" : "Example course name\nExample course name for widget preview", @@ -2826,6 +2829,12 @@ } } } + }, + "Chapter 5 Discussion" : { + + }, + "Chemistry Lab" : { + }, "Course" : { "localizations" : { @@ -3339,6 +3348,9 @@ } } } + }, + "Course Syllabus" : { + }, "Double tap to view course grades tab" : { "localizations" : { @@ -6933,6 +6945,9 @@ } } } + }, + "Guest Lecture Series" : { + }, "Hidden Grades" : { "localizations" : { @@ -7189,6 +7204,9 @@ } } } + }, + "History 202" : { + }, "In the Jungle" : { "comment" : "Example course name for widget preview", @@ -7446,6 +7464,9 @@ } } } + }, + "Introduction to Psychology" : { + }, "Introduction to the Solar System" : { "comment" : "Example course name", @@ -7960,6 +7981,9 @@ } } } + }, + "Lab Report Submission" : { + }, "Let's Get You Logged In!" : { "localizations" : { @@ -9242,6 +9266,9 @@ } } } + }, + "Modern Literature" : { + }, "No Announcements" : { "localizations" : { @@ -11805,6 +11832,12 @@ } } } + }, + "Research Paper Draft" : { + + }, + "Review study materials" : { + }, "Select Course" : { "localizations" : { @@ -13856,6 +13889,9 @@ } } } + }, + "To Do" : { + }, "To see your grades, please log in to your account in the app." : { "localizations" : { @@ -15395,6 +15431,9 @@ } } } + }, + "Unit 3 Quiz" : { + }, "View Full List" : { "localizations" : { diff --git a/Student/Widgets/TodoWidget/Model/TodoModel.swift b/Student/Widgets/TodoWidget/Model/TodoModel.swift index 1466d4ac91..94ddcdc333 100644 --- a/Student/Widgets/TodoWidget/Model/TodoModel.swift +++ b/Student/Widgets/TodoWidget/Model/TodoModel.swift @@ -66,36 +66,83 @@ extension TodoModel { static func make(count: Int = 5) -> TodoModel { let items = [ - TodoItemViewModel.make( + TodoItemViewModel( id: "1", type: .assignment, - date: Date.now, title: "Important Assignment", - contextName: "Student", - color: .orange, + date: Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: Date.now)!, + title: String(localized: "Research Paper Draft"), + subtitle: nil, + contextName: String(localized: "Introduction to Psychology"), + htmlURL: nil, + color: .course3, icon: .assignmentLine ), - TodoItemViewModel.make( + TodoItemViewModel( id: "2", type: .discussion_topic, - date: Date.now, - title: "Discussion About Everything", - contextName: "Student", - color: .orange, + date: Calendar.current.date(bySettingHour: 12, minute: 0, second: 0, of: Date.now)!, + title: String(localized: "Chapter 5 Discussion"), + subtitle: nil, + contextName: String(localized: "Modern Literature"), + htmlURL: nil, + color: .course8, icon: .discussionLine ), - TodoItemViewModel.make( + TodoItemViewModel( id: "3", type: .calendar_event, - date: Date.now, - title: "Huge Event", - contextName: "Student", - color: .orange, + date: Calendar.current.date(bySettingHour: 15, minute: 0, second: 0, of: Date.now)!, + title: String(localized: "Guest Lecture Series"), + subtitle: nil, + contextName: String(localized: "Biology 101"), + htmlURL: nil, + color: .course5, icon: .calendarMonthLine ), - TodoItemViewModel.make(id: "4", type: .planner_note, date: Date.now.addDays(3), title: "Don't forget", icon: .noteLine), - TodoItemViewModel.make(id: "5", type: .quiz, date: Date.now.addDays(3), title: "Quiz About Life", icon: .quizLine), - TodoItemViewModel.make(id: "6", type: .assignment, date: Date.now.addDays(3), title: "Another Assignment", icon: .assignmentLine), - TodoItemViewModel.make(id: "7", type: .wiki_page, date: Date.now.addDays(3), title: "Some Page", icon: .documentLine) + TodoItemViewModel( + id: "4", + type: .planner_note, + date: Calendar.current.date(bySettingHour: 10, minute: 0, second: 0, of: Date.now.addDays(3))!, + title: String(localized: "Review study materials"), + subtitle: nil, + contextName: String(localized: "To Do"), + htmlURL: nil, + color: .course11, + icon: .noteLine + ), + TodoItemViewModel( + id: "5", + type: .quiz, + date: Calendar.current.date(bySettingHour: 14, minute: 0, second: 0, of: Date.now.addDays(3))!, + title: String(localized: "Unit 3 Quiz"), + subtitle: nil, + contextName: String(localized: "Introduction to Psychology"), + htmlURL: nil, + color: .course3, + icon: .quizLine + ), + TodoItemViewModel( + id: "6", + type: .assignment, + date: Calendar.current.date(bySettingHour: 16, minute: 0, second: 0, of: Date.now.addDays(3))!, + title: String(localized: "Lab Report Submission"), + subtitle: nil, + contextName: String(localized: "Chemistry Lab"), + htmlURL: nil, + color: .course12, + icon: .assignmentLine + ), + TodoItemViewModel( + id: "7", + type: .wiki_page, + date: Calendar.current.date(bySettingHour: 18, minute: 0, second: 0, of: Date.now.addDays(3))!, + title: String(localized: "Course Syllabus"), + subtitle: nil, + contextName: String(localized: "History 202"), + htmlURL: nil, + color: .course6, + icon: .documentLine + ) ] return TodoModel(items: Array(items.prefix(count))) } From 7e0273c98a76dfd4b7cbe19ac7903fd7414ab2b1 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 2 Oct 2025 15:21:30 +0200 Subject: [PATCH 22/49] Update dividers for better section scrolling experience. --- .../Todos/View/TodoItemContentView.swift | 1 - .../Todos/View/TodoListItemCell.swift | 2 ++ .../Features/Todos/View/TodoListScreen.swift | 34 +++++++++++++------ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/Core/Core/Features/Todos/View/TodoItemContentView.swift b/Core/Core/Features/Todos/View/TodoItemContentView.swift index d42dbd6c6b..7e4dc88b70 100644 --- a/Core/Core/Features/Todos/View/TodoItemContentView.swift +++ b/Core/Core/Features/Todos/View/TodoItemContentView.swift @@ -41,7 +41,6 @@ public struct TodoItemContentView: View { titleSection timeSection } - .background(Color.backgroundLightest) .multilineTextAlignment(.leading) } diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index 3ee0b8b56f..6248c51fa9 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -36,6 +36,8 @@ struct TodoListItemCell: View { .paddingStyle(.leading, .cellAccessoryPadding) .accessibilityHidden(true) } + .padding(.vertical, 8) + .background(.backgroundLightest) } .accessibilityElement(children: .combine) } diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 149d746628..245f1e7f03 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -36,9 +36,11 @@ public struct TodoListScreen: View { } ) { _ in LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + InstUI.Divider() ForEach(viewModel.items) { group in groupView(for: group) } + InstUI.Divider() } .paddingStyle(.horizontal, .standard) } @@ -54,23 +56,33 @@ public struct TodoListScreen: View { item: item, onTap: viewModel.didTapItem ) - .padding(.vertical, 8) .padding(.leading, 48) let isLastItemInGroup = (group.items.last == item) - InstUI.Divider().padding(.leading, isLastItemInGroup ? 0 : 48) + + if !isLastItemInGroup { + InstUI.Divider().padding(.leading, 48) + } } } header: { - TodoDayHeaderView(group: group) { group in - viewModel.didTapDayHeader(group, viewController: viewController) + VStack(spacing: 0) { + let isFirstSection = (viewModel.items.first == group) + + if !isFirstSection { + InstUI.Divider() + } + + TodoDayHeaderView(group: group) { group in + viewModel.didTapDayHeader(group, viewController: viewController) + } + // To provide a large enough hit area, the header needs to include padding + // but the screen already has a padding so we need to negate that here. + .padding(.leading, -InstUI.Styles.Padding.standard.rawValue) + // Move day badge to the left of the screen. + .frame(maxWidth: .infinity, alignment: .leading) + // Squeeze height to 0 so day badge goes next to cell. + .frame(height: 0, alignment: .top) } - // To provide a large enough hit area, the header needs to include padding - // but the screen already has a padding so we need to negate that here. - .padding(.leading, -InstUI.Styles.Padding.standard.rawValue) - // Move day badge to the left of the screen. - .frame(maxWidth: .infinity, alignment: .leading) - // Squeeze height to 0 so day badge goes next to cell. - .frame(height: 0, alignment: .top) } } From 80a6c59cacaadd676fcc2840d968696a5dc1ed43 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Fri, 3 Oct 2025 10:41:47 +0200 Subject: [PATCH 23/49] Fix flaky test. --- .../Model/StudentAssignmentListItemTests.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Core/CoreTests/Features/Assignments/AssignmentList/Model/StudentAssignmentListItemTests.swift b/Core/CoreTests/Features/Assignments/AssignmentList/Model/StudentAssignmentListItemTests.swift index 69306bfc9b..ec52e6b873 100644 --- a/Core/CoreTests/Features/Assignments/AssignmentList/Model/StudentAssignmentListItemTests.swift +++ b/Core/CoreTests/Features/Assignments/AssignmentList/Model/StudentAssignmentListItemTests.swift @@ -347,19 +347,20 @@ final class StudentAssignmentListItemTests: CoreTestCase { // MARK: - Submission for UserId - func test_submission_whenUserIdIsNil_shouldBeFirstSubmission() { + func test_submission_whenUserIdIsNilAndThereIsOnlyOneSubmission_shouldBeFirstAndOnlySubmission() { testee = makeListItem( .make( submissions: [ - .make(excused: false), - .make(excused: true, user_id: "42"), - .make(excused: false, user_id: "7") + // We are testing with only one submission, because `Assignment.submissions` + // is a `Set` and testing `first` on it would be flaky. + // This matches assumed behavior, because Student app expects only one submission per assignment. + .make(excused: true, user_id: "42") ] ), userId: nil ) - XCTAssertEqual(testee.submissionStatus, .init(status: .submitted)) + XCTAssertEqual(testee.submissionStatus, .init(status: .excused)) } func test_submission_whenUserIdIsSet_shouldBeSubmissionMatchingUserId() { From 38b14332b8e9029f26fc120f63e4706a0b14314b Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 7 Oct 2025 15:14:52 +0200 Subject: [PATCH 24/49] Optimize course lookup. --- Core/Core/Features/Todos/Model/TodoInteractor.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index 7a5f6b3061..af49097af1 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -59,10 +59,9 @@ public final class TodoInteractorLive: TodoInteractor { ) .getEntities(ignoreCache: ignoreCache, loadAllPages: true) .map { plannables in + let coursesByCanvasContextIds = Dictionary(uniqueKeysWithValues: courses.map { ($0.canvasContextID, $0) }) return plannables.compactMap { plannable in - let course = courses.first { course in - course.canvasContextID == plannable.canvasContextIDRaw - } + let course = coursesByCanvasContextIds[plannable.canvasContextIDRaw ?? ""] return TodoItemViewModel(plannable, course: course) } } From 92cc8474d5e4ed1f7149858b33cf2f766b5d84b0 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 15 Oct 2025 12:40:00 +0200 Subject: [PATCH 25/49] Implement code review suggestions. Improve a11y. --- .../CommonUI/InstUI/Views/Divider.swift | 16 ++++++-- .../Todos/View/TodoDayHeaderView.swift | 22 +++++++++-- .../Todos/View/TodoItemContentView.swift | 39 +++++++++++-------- .../Features/Todos/View/TodoListScreen.swift | 16 ++++---- .../Todos/ViewModel/TodoGroupViewModel.swift | 4 +- .../Todos/ViewModel/TodoItemViewModel.swift | 2 +- .../Todos/ViewModel/TodoListViewModel.swift | 2 +- Core/Core/Resources/Localizable.xcstrings | 2 +- .../Todos/Model/TodoInteractorLiveTests.swift | 20 +++++----- .../ViewModel/TodoGroupViewModelTests.swift | 12 +++--- .../ViewModel/TodoListViewModelTests.swift | 14 +++---- 11 files changed, 90 insertions(+), 59 deletions(-) diff --git a/Core/Core/Common/CommonUI/InstUI/Views/Divider.swift b/Core/Core/Common/CommonUI/InstUI/Views/Divider.swift index 455cc4f112..75ce802271 100644 --- a/Core/Core/Common/CommonUI/InstUI/Views/Divider.swift +++ b/Core/Core/Common/CommonUI/InstUI/Views/Divider.swift @@ -58,16 +58,26 @@ extension InstUI { /// It can be used before the first item of a list to make it /// have a top divider visible only when it is scrolled down. public struct TopDivider: View { + private static let offset: CGFloat = 1 private let style: Divider.Style + private let backgroundColor: Color - public init(_ style: Divider.Style = .full) { + public init( + _ style: Divider.Style = .full, + backgroundColor: Color = .backgroundLightest + ) { self.style = style + self.backgroundColor = backgroundColor } public var body: some View { - InstUI.Divider(style) - .offset(y: -1) + backgroundColor + .frame(height: Self.offset) + .overlay { + InstUI.Divider(style) + .offset(y: -Self.offset) + } } } } diff --git a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift index 2981aa9067..d46ada6c9e 100644 --- a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift +++ b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift @@ -20,6 +20,7 @@ import SwiftUI struct TodoDayHeaderView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ScaledMetric private var uiScale: CGFloat = 1 let group: TodoGroupViewModel let onTap: (TodoGroupViewModel) -> Void @@ -41,16 +42,16 @@ struct TodoDayHeaderView: View { ZStack { Circle() .stroke(tintColor) - .frame(width: 32, height: 32) + .scaledFrame(size: 32, useIconScale: true) .hidden(!group.isToday) Text(group.dayNumber) .font(group.isToday ? .bold12 : .regular12, lineHeight: .fit) - .padding(.top, group.isToday ? 0 : -14) + .padding(.top, group.isToday ? 0 : uiScale * -14) } } .padding(.top, 8) .padding(.bottom, 9) // to maximize hit area - .frame(width: 64, alignment: .center) + .frame(width: Self.headerWidth(uiScale), alignment: .center) .foregroundStyle(tintColor) .contentShape(Rectangle()) } @@ -59,6 +60,21 @@ struct TodoDayHeaderView: View { .accessibilityLabel(group.accessibilityLabel) .accessibilityAddTraits(.isHeader) } + + static func headerWidth(_ uiScale: CGFloat) -> CGFloat { + 64 * uiScale.todoHeaderWidthScale + } +} + +private extension CGFloat { + + var todoHeaderWidthScale: CGFloat { + if self > 1 { + return 1 + 0.2 * (self - 1) + } else { + return self + } + } } #if DEBUG diff --git a/Core/Core/Features/Todos/View/TodoItemContentView.swift b/Core/Core/Features/Todos/View/TodoItemContentView.swift index 7e4dc88b70..00a92a86dc 100644 --- a/Core/Core/Features/Todos/View/TodoItemContentView.swift +++ b/Core/Core/Features/Todos/View/TodoItemContentView.swift @@ -25,6 +25,7 @@ public struct TodoItemContentView: View { public let item: TodoItemViewModel public let isCompactLayout: Bool + private let verticalSpacing: CGFloat /// Initializes a TodoItemContentView /// - Parameters: @@ -33,10 +34,11 @@ public struct TodoItemContentView: View { public init(item: TodoItemViewModel, isCompactLayout: Bool) { self.item = item self.isCompactLayout = isCompactLayout + self.verticalSpacing = isCompactLayout ? 0 : 2 } public var body: some View { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: verticalSpacing) { contextSection titleSection timeSection @@ -45,24 +47,27 @@ public struct TodoItemContentView: View { } private var contextSection: some View { - HStack(spacing: 5) { - item.icon - .scaledIcon(size: 16) - .foregroundStyle(item.color) - .accessibilityHidden(true) - .frame(maxHeight: .infinity, alignment: .top) - .padding(.top, isCompactLayout ? 0 : 2) - InstUI.Divider() - Text(item.contextName) - .foregroundStyle(item.color) - .font(isCompactLayout ? .regular12 : .regular14, lineHeight: .fit) - .lineLimit(isCompactLayout ? 1 : nil) - } + InstUI.JoinedSubtitleLabels( + label1: { + item.icon + .scaledIcon(size: 16) + .foregroundStyle(item.color) + .accessibilityHidden(true) + .padding(.top, uiScale * (isCompactLayout ? 1 : 2)) + }, + label2: { + Text(item.contextName) + .foregroundStyle(item.color) + .font(isCompactLayout ? .regular12 : .regular14, lineHeight: .fit) + .lineLimit(isCompactLayout ? 1 : nil) + }, + alignment: .top + ) .fixedSize(horizontal: false, vertical: true) } private var titleSection: some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: verticalSpacing) { Text(item.title) .font(isCompactLayout ? .semibold14 : .regular16, lineHeight: .fit) .foregroundStyle(.textDarkest) @@ -87,10 +92,12 @@ public struct TodoItemContentView: View { #if DEBUG -#Preview(traits: .fixedLayout(width: 300, height: 400)) { +#Preview(traits: .fixedLayout(width: 200, height: 400)) { VStack { TodoItemContentView(item: .make(), isCompactLayout: true) + .background(.backgroundLightest) TodoItemContentView(item: .make(), isCompactLayout: false) + .background(.backgroundLightest) } .frame(maxHeight: .infinity) .background(Color.backgroundDarkest) diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 245f1e7f03..a3155e8faa 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -22,6 +22,8 @@ public struct TodoListScreen: View { @Environment(\.viewController) private var viewController @Environment(\.dynamicTypeSize) private var dynamicTypeSize @ObservedObject var viewModel: TodoListViewModel + /// The sticky section header grows horizontally, so we need to increase paddings here not to let the header overlap the cell content. + @ScaledMetric private var uiScale: CGFloat = 1 public init(viewModel: TodoListViewModel) { self.viewModel = viewModel @@ -36,13 +38,13 @@ public struct TodoListScreen: View { } ) { _ in LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - InstUI.Divider() + InstUI.TopDivider() ForEach(viewModel.items) { group in groupView(for: group) } + .paddingStyle(.trailing, .standard) InstUI.Divider() } - .paddingStyle(.horizontal, .standard) } .clipped() .navigationBarItems(leading: profileMenuButton) @@ -51,17 +53,18 @@ public struct TodoListScreen: View { @ViewBuilder private func groupView(for group: TodoGroupViewModel) -> some View { Section { + let leadingPadding = TodoDayHeaderView.headerWidth(uiScale) ForEach(group.items) { item in TodoListItemCell( item: item, onTap: viewModel.didTapItem ) - .padding(.leading, 48) + .padding(.leading, leadingPadding) let isLastItemInGroup = (group.items.last == item) if !isLastItemInGroup { - InstUI.Divider().padding(.leading, 48) + InstUI.Divider().padding(.leading, leadingPadding) } } } header: { @@ -69,15 +72,12 @@ public struct TodoListScreen: View { let isFirstSection = (viewModel.items.first == group) if !isFirstSection { - InstUI.Divider() + InstUI.Divider().paddingStyle(.leading, .standard) } TodoDayHeaderView(group: group) { group in viewModel.didTapDayHeader(group, viewController: viewController) } - // To provide a large enough hit area, the header needs to include padding - // but the screen already has a padding so we need to negate that here. - .padding(.leading, -InstUI.Styles.Padding.standard.rawValue) // Move day badge to the left of the screen. .frame(maxWidth: .infinity, alignment: .leading) // Squeeze height to 0 so day badge goes next to cell. diff --git a/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift index 2b6fb4d1f0..ee5aaf75cd 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift @@ -39,8 +39,8 @@ public struct TodoGroupViewModel: Identifiable, Equatable, Comparable { self.accessibilityLabel = [ date.weekdayName, date.dayString, - String.format(numberOfItems: items.count) - ].joined(separator: ", ") + String.format(numberOfItems: items.count) as String + ].accessibilityJoined() } // MARK: - Comparable diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index aa20b89a81..1cdbb4e18e 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -54,7 +54,7 @@ public struct TodoItemViewModel: Identifiable, Equatable, Comparable { self.htmlURL = plannable.htmlURL self.color = plannable.color.asColor - self.icon = Image(uiImage: plannable.icon) + self.icon = plannable.icon.asImage } /// Helper function to determine the context name for a Todo item. diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index 218ad3222d..d5bc728fad 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -27,7 +27,7 @@ public class TodoListViewModel: ObservableObject { let screenConfig = InstUI.BaseScreenConfig( emptyPandaConfig: .init( scene: VacationPanda(), - title: String(localized: "No To Dos for now!", bundle: .core), + title: String(localized: "No To-dos for now!", bundle: .core), subtitle: String(localized: "It looks like a great time to rest, relax, and recharge.", bundle: .core) ) ) diff --git a/Core/Core/Resources/Localizable.xcstrings b/Core/Core/Resources/Localizable.xcstrings index 32b13cdf55..1f1cc1e7d2 100644 --- a/Core/Core/Resources/Localizable.xcstrings +++ b/Core/Core/Resources/Localizable.xcstrings @@ -239986,7 +239986,7 @@ } } }, - "No To Dos for now!" : { + "No To-dos for now!" : { }, "None" : { diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift index fb2768fc73..7d6b826492 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift @@ -71,14 +71,14 @@ class TodoInteractorLiveTests: CoreTestCase { XCTAssertEqual(todoGroups.count, 2) // Check first group (today) - let firstGroup = todoGroups[0] - XCTAssertEqual(firstGroup.items.count, 1) - XCTAssertEqual(firstGroup.items[0].title, "Assignment 1") + let firstGroup = todoGroups.first + XCTAssertEqual(firstGroup?.items.count, 1) + XCTAssertEqual(firstGroup?.items.first?.title, "Assignment 1") // Check second group (tomorrow) - let secondGroup = todoGroups[1] - XCTAssertEqual(secondGroup.items.count, 1) - XCTAssertEqual(secondGroup.items[0].title, "Quiz 1") + let secondGroup = todoGroups.last + XCTAssertEqual(secondGroup?.items.count, 1) + XCTAssertEqual(secondGroup?.items.first?.title, "Quiz 1") } } @@ -112,8 +112,8 @@ class TodoInteractorLiveTests: CoreTestCase { XCTAssertFinish(testee.refresh(ignoreCache: false)) XCTAssertFirstValue(testee.todoGroups) { todoGroups in XCTAssertEqual(todoGroups.count, 1) - XCTAssertEqual(todoGroups[0].items.count, 1) - XCTAssertEqual(todoGroups[0].items[0].title, "Assignment 1") + XCTAssertEqual(todoGroups.first?.items.count, 1) + XCTAssertEqual(todoGroups.first?.items.first?.title, "Assignment 1") } } @@ -147,7 +147,7 @@ class TodoInteractorLiveTests: CoreTestCase { XCTAssertFirstValue(testee.todoGroups) { todos in XCTAssertEqual(todos.count, 1) - XCTAssertEqual(todos[0].items[0].title, "Assignment 1") + XCTAssertEqual(todos.first?.items.first?.title, "Assignment 1") } } @@ -166,7 +166,7 @@ class TodoInteractorLiveTests: CoreTestCase { XCTAssertFinish(testee.refresh(ignoreCache: false)) XCTAssertFirstValue(testee.todoGroups) { todos in XCTAssertEqual(todos.count, 1) - XCTAssertEqual(todos[0].items[0].title, "Assignment 2") + XCTAssertEqual(todos.first?.items.first?.title, "Assignment 2") } } diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift index 3668c7ac29..d710c80bb0 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift @@ -23,20 +23,18 @@ import XCTest class TodoGroupViewModelTests: CoreTestCase { func testAccessibilityLabel() { - let dateComponents = DateComponents(year: 2021, month: 8, day: 7, hour: 12) - let date = Calendar.current.date(from: dateComponents)! + let date = Date.make(year: 2021, month: 8, day: 7, hour: 12) let items = [TodoItemViewModel.make(id: "1"), TodoItemViewModel.make(id: "2")] let group = TodoGroupViewModel(date: date, items: items) - XCTAssertTrue(group.accessibilityLabel.contains("Saturday")) - XCTAssertTrue(group.accessibilityLabel.contains("7")) - XCTAssertTrue(group.accessibilityLabel.contains("2 items")) + XCTAssertContains(group.accessibilityLabel, "Saturday") + XCTAssertContains(group.accessibilityLabel, "7") + XCTAssertContains(group.accessibilityLabel, "2 items") } func testDateFormatting() { - let dateComponents = DateComponents(year: 2021, month: 12, day: 25, hour: 15) - let date = Calendar.current.date(from: dateComponents)! + let date = Date.make(year: 2021, month: 12, day: 25, hour: 15) let items = [TodoItemViewModel.make(id: "1")] let group = TodoGroupViewModel(date: date, items: items) diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift index 80c4811133..93eabe5e71 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift @@ -72,7 +72,7 @@ class TodoListViewModelTests: CoreTestCase { func testRefreshWithIgnoreCacheTrue() { // Given let expectation = expectation(description: "Refresh completion called") - interactor.refreshResult = .success(()) + interactor.refreshResult = .success // When testee.refresh(completion: { @@ -90,7 +90,7 @@ class TodoListViewModelTests: CoreTestCase { func testRefreshWithIgnoreCacheFalse() { // Given let expectation = expectation(description: "Refresh completion called") - interactor.refreshResult = .success(()) + interactor.refreshResult = .success // When testee.refresh(completion: { @@ -107,7 +107,7 @@ class TodoListViewModelTests: CoreTestCase { func testRefreshSuccessWithNonEmptyData() { // Given let expectation = expectation(description: "Refresh completion called") - interactor.refreshResult = .success(()) + interactor.refreshResult = .success interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [TodoItemViewModel.make(id: "1", title: "Test Item")])]) // When @@ -123,7 +123,7 @@ class TodoListViewModelTests: CoreTestCase { func testRefreshSuccessWithEmptyData() { // Given let expectation = expectation(description: "Refresh completion called") - interactor.refreshResult = .success(()) + interactor.refreshResult = .success interactor.todoGroupsSubject.send([]) // When @@ -212,7 +212,7 @@ class TodoListViewModelTests: CoreTestCase { XCTAssertEqual(testee.state, .empty) // When - with non-empty todos - interactor.refreshResult = .success(()) + interactor.refreshResult = .success interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [TodoItemViewModel.make(id: "1", title: "Test")])]) testee.refresh(completion: {}, ignoreCache: false) @@ -220,7 +220,7 @@ class TodoListViewModelTests: CoreTestCase { XCTAssertEqual(testee.state, .data) // When - with empty todos - interactor.refreshResult = .success(()) + interactor.refreshResult = .success interactor.todoGroupsSubject.send([]) testee.refresh(completion: {}, ignoreCache: false) @@ -238,7 +238,7 @@ class TodoListViewModelTests: CoreTestCase { func testMultipleRefreshCalls() { // Given interactor.refreshCallCount = 0 - interactor.refreshResult = .success(()) + interactor.refreshResult = .success // When testee.refresh(completion: {}, ignoreCache: false) From 6b51e2a0f6a56b632255ed8414e97b255f2ff87e Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 8 Oct 2025 10:46:52 +0200 Subject: [PATCH 26/49] Add DB fields required for mark as done feature. --- .../Planner/Model/CoreData/Plannable.swift | 11 ++++++++ .../Database.xcdatamodel/contents | 7 +++-- .../Model/CoreData/PlannableTests.swift | 26 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Core/Core/Features/Planner/Model/CoreData/Plannable.swift b/Core/Core/Features/Planner/Model/CoreData/Plannable.swift index 1f047e5a2b..61cc01840b 100644 --- a/Core/Core/Features/Planner/Model/CoreData/Plannable.swift +++ b/Core/Core/Features/Planner/Model/CoreData/Plannable.swift @@ -44,6 +44,14 @@ public final class Plannable: NSManagedObject { @NSManaged public var pointsPossibleRaw: NSNumber? @NSManaged public var userID: String? @NSManaged public var details: String? + // MARK: - Planner Override Fields + @NSManaged public var plannerOverrideId: String? + /// Default value is `false`. + @NSManaged public var isMarkedComplete: Bool + /// Default value is `false`. + @NSManaged public var isSubmitted: Bool + // MARK: - + @NSManaged private var discussionCheckpointStepRaw: DiscussionCheckpointStepWrapper? public var discussionCheckpointStep: DiscussionCheckpointStep? { get { return discussionCheckpointStepRaw?.value } set { discussionCheckpointStepRaw = .init(value: newValue) } @@ -88,6 +96,9 @@ public final class Plannable: NSManagedObject { tag: item.plannable?.sub_assignment_tag, requiredReplyCount: item.details?.reply_to_entry_required_count ) + model.plannerOverrideId = item.planner_override?.id.value + model.isMarkedComplete = item.planner_override?.marked_complete ?? false + model.isSubmitted = item.submissions?.value1?.submitted ?? false return model } diff --git a/Core/Core/Resources/Database.xcdatamodeld/Database.xcdatamodel/contents b/Core/Core/Resources/Database.xcdatamodeld/Database.xcdatamodel/contents index 5fe010eec1..88e4beff06 100644 --- a/Core/Core/Resources/Database.xcdatamodeld/Database.xcdatamodel/contents +++ b/Core/Core/Resources/Database.xcdatamodeld/Database.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -1208,7 +1208,10 @@ + + + @@ -1453,4 +1456,4 @@ - + \ No newline at end of file diff --git a/Core/CoreTests/Features/Planner/Model/CoreData/PlannableTests.swift b/Core/CoreTests/Features/Planner/Model/CoreData/PlannableTests.swift index d2c4c0a0d9..8fc8f3a8d5 100644 --- a/Core/CoreTests/Features/Planner/Model/CoreData/PlannableTests.swift +++ b/Core/CoreTests/Features/Planner/Model/CoreData/PlannableTests.swift @@ -67,6 +67,32 @@ class PlannableTests: CoreTestCase { XCTAssertEqual(plannable.context?.courseId, TestConstants.courseId) XCTAssertEqual(plannable.userID, "another userId") XCTAssertEqual(plannable.discussionCheckpointStep, .requiredReplies(42)) + XCTAssertNil(plannable.plannerOverrideId) + XCTAssertFalse(plannable.isMarkedComplete) + XCTAssertFalse(plannable.isSubmitted) + } + + func testSaveAPIPlannableWithMarkedComplete() { + let apiPlannable = APIPlannable.make( + planner_override: .make(id: "override-123", marked_complete: true), + plannable_id: ID(TestConstants.plannableId) + ) + + let plannable = Plannable.save(apiPlannable, userId: nil, in: databaseClient) + + XCTAssertEqual(plannable.plannerOverrideId, "override-123") + XCTAssertTrue(plannable.isMarkedComplete) + } + + func testSaveAPIPlannableWithSubmitted() { + let apiPlannable = APIPlannable.make( + plannable_id: ID(TestConstants.plannableId), + submissions: .make(submitted: true) + ) + + let plannable = Plannable.save(apiPlannable, userId: nil, in: databaseClient) + + XCTAssertTrue(plannable.isSubmitted) } func testSaveAPIPlannerNote() { From 8354bed65539059eec81ebf3e09d0796283b6acb Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 15 Oct 2025 17:33:01 +0200 Subject: [PATCH 27/49] Add MarkPlannableItemDone use case with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Planner/MarkPlannableItemDone.swift | 103 +++++++++++ .../Planner/MarkPlannableItemDoneTests.swift | 171 ++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 Core/Core/Features/Planner/MarkPlannableItemDone.swift create mode 100644 Core/CoreTests/Features/Planner/MarkPlannableItemDoneTests.swift diff --git a/Core/Core/Features/Planner/MarkPlannableItemDone.swift b/Core/Core/Features/Planner/MarkPlannableItemDone.swift new file mode 100644 index 0000000000..5881a7ec22 --- /dev/null +++ b/Core/Core/Features/Planner/MarkPlannableItemDone.swift @@ -0,0 +1,103 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Foundation +import CoreData + +public struct MarkPlannableItemDone: UseCase { + public typealias Model = Plannable + public typealias Response = APIPlannerOverride + + public let cacheKey: String? = nil + public let scope: Scope + + public let plannableId: String + public let plannableType: String + public let overrideId: String? + public let done: Bool + + public init( + plannableId: String, + plannableType: String, + overrideId: String?, + done: Bool + ) { + self.plannableId = plannableId + self.plannableType = plannableType + self.overrideId = overrideId + self.done = done + self.scope = .plannable(id: plannableId) + } + + public func makeRequest(environment: AppEnvironment, completionHandler: @escaping RequestCallback) { + if let overrideId = overrideId { + updateExistingOverride(overrideId: overrideId, environment: environment, completionHandler: completionHandler) + } else { + createNewOverride(environment: environment, completionHandler: completionHandler) + } + } + + public func write(response: APIPlannerOverride?, urlResponse: URLResponse?, to client: NSManagedObjectContext) { + guard let response = response, + let plannable: Plannable = client.fetch(scope: scope).first + else { + return + } + + plannable.plannerOverrideId = response.id.value + plannable.isMarkedComplete = done + } + + public func reset(context: NSManagedObjectContext) { + } + + // MARK: - Private Methods + + private func createNewOverride(environment: AppEnvironment, completionHandler: @escaping RequestCallback) { + let request = CreatePlannerOverrideRequest( + body: .init( + plannable_type: plannableType, + plannable_id: plannableId, + marked_complete: done + ) + ) + environment.api.makeRequest(request) { (response: APIPlannerOverride?, urlResponse, error) in + completionHandler(response, urlResponse, error) + } + } + + private func updateExistingOverride(overrideId: String, environment: AppEnvironment, completionHandler: @escaping RequestCallback) { + let request = UpdatePlannerOverrideRequest( + overrideId: overrideId, + body: .init(marked_complete: done) + ) + let updatedOverride = APIPlannerOverride.make( + id: ID(overrideId), + plannable_type: plannableType, + plannable_id: ID(plannableId), + marked_complete: done + ) + environment.api.makeRequest(request) { (response: APINoContent?, urlResponse, error) in + if error == nil { + completionHandler(updatedOverride, urlResponse, error) + } else { + completionHandler(nil, urlResponse, error) + } + } + } +} diff --git a/Core/CoreTests/Features/Planner/MarkPlannableItemDoneTests.swift b/Core/CoreTests/Features/Planner/MarkPlannableItemDoneTests.swift new file mode 100644 index 0000000000..0913e9c07b --- /dev/null +++ b/Core/CoreTests/Features/Planner/MarkPlannableItemDoneTests.swift @@ -0,0 +1,171 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +@testable import Core +import XCTest + +class MarkPlannableItemDoneTests: CoreTestCase { + + func test_makeRequest_createsOverrideSuccessfully() { + let plannable = Plannable.save( + APIPlannable.make(plannable_id: ID("123")), + userId: nil, + in: databaseClient + ) + + XCTAssertFalse(plannable.isMarkedComplete) + XCTAssertNil(plannable.plannerOverrideId) + + let useCase = MarkPlannableItemDone( + plannableId: "123", + plannableType: "assignment", + overrideId: nil, + done: true + ) + + let createRequest = CreatePlannerOverrideRequest( + body: .init( + plannable_type: "assignment", + plannable_id: "123", + marked_complete: true + ) + ) + let mockResponse = APIPlannerOverride.make( + id: "override-789", + marked_complete: true + ) + api.mock(createRequest, value: mockResponse) + + let expectation = XCTestExpectation(description: "request completes") + useCase.makeRequest(environment: environment) { response, _, error in + XCTAssertNil(error) + XCTAssertEqual(response?.id.value, "override-789") + useCase.write(response: response, urlResponse: nil, to: self.databaseClient) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + databaseClient.refresh() + + XCTAssertTrue(plannable.isMarkedComplete) + XCTAssertEqual(plannable.plannerOverrideId, "override-789") + } + + func test_makeRequest_updatesOverrideSuccessfully() { + let plannable = Plannable.save( + APIPlannable.make( + planner_override: .make(id: "override-123", marked_complete: true), + plannable_id: ID("123") + ), + userId: nil, + in: databaseClient + ) + + XCTAssertTrue(plannable.isMarkedComplete) + XCTAssertEqual(plannable.plannerOverrideId, "override-123") + + let useCase = MarkPlannableItemDone( + plannableId: "123", + plannableType: "assignment", + overrideId: "override-123", + done: false + ) + + let updateRequest = UpdatePlannerOverrideRequest( + overrideId: "override-123", + body: .init(marked_complete: false) + ) + api.mock(updateRequest, value: APINoContent()) + + let expectation = XCTestExpectation(description: "request completes") + useCase.makeRequest(environment: environment) { response, _, error in + XCTAssertNil(error) + XCTAssertEqual(response?.id.value, "override-123") + XCTAssertEqual(response?.marked_complete, false) + useCase.write(response: response, urlResponse: nil, to: self.databaseClient) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + databaseClient.refresh() + + XCTAssertFalse(plannable.isMarkedComplete) + XCTAssertEqual(plannable.plannerOverrideId, "override-123") + } + + func test_makeRequest_onError_doesNotUpdateDatabase() { + let plannable = Plannable.save( + APIPlannable.make(plannable_id: ID("123")), + userId: nil, + in: databaseClient + ) + + XCTAssertFalse(plannable.isMarkedComplete) + + let useCase = MarkPlannableItemDone( + plannableId: "123", + plannableType: "assignment", + overrideId: nil, + done: true + ) + + let createRequest = CreatePlannerOverrideRequest( + body: .init( + plannable_type: "assignment", + plannable_id: "123", + marked_complete: true + ) + ) + api.mock(createRequest, error: NSError.instructureError("test error")) + + let expectation = XCTestExpectation(description: "request completes") + useCase.makeRequest(environment: environment) { response, _, error in + XCTAssertNotNil(error) + XCTAssertNil(response) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + databaseClient.refresh() + + XCTAssertFalse(plannable.isMarkedComplete) + XCTAssertNil(plannable.plannerOverrideId) + } + + func test_cacheKey_isNil() { + let useCase = MarkPlannableItemDone( + plannableId: "123", + plannableType: "assignment", + overrideId: nil, + done: true + ) + + XCTAssertNil(useCase.cacheKey) + } + + func test_scope_usesPlannableId() { + let useCase = MarkPlannableItemDone( + plannableId: "123", + plannableType: "assignment", + overrideId: nil, + done: true + ) + + XCTAssertEqual(useCase.scope, .plannable(id: "123")) + } +} From b3aab41f101efcf11be8b7b5879e7116669c2bef Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 15 Oct 2025 17:41:54 +0200 Subject: [PATCH 28/49] Convert TodoItemViewModel to class with mark-as-done state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Planner/MarkPlannableItemDone.swift | 2 +- .../Todos/ViewModel/TodoItemViewModel.swift | 30 +++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Core/Core/Features/Planner/MarkPlannableItemDone.swift b/Core/Core/Features/Planner/MarkPlannableItemDone.swift index 5881a7ec22..9124caf82f 100644 --- a/Core/Core/Features/Planner/MarkPlannableItemDone.swift +++ b/Core/Core/Features/Planner/MarkPlannableItemDone.swift @@ -92,7 +92,7 @@ public struct MarkPlannableItemDone: UseCase { plannable_id: ID(plannableId), marked_complete: done ) - environment.api.makeRequest(request) { (response: APINoContent?, urlResponse, error) in + environment.api.makeRequest(request) { (_: APINoContent?, urlResponse, error) in if error == nil { completionHandler(updatedOverride, urlResponse, error) } else { diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index 1cdbb4e18e..e539232e3c 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -17,8 +17,15 @@ // import SwiftUI +import Combine -public struct TodoItemViewModel: Identifiable, Equatable, Comparable { +public enum MarkDoneState: Equatable { + case notDone + case loading + case done +} + +public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableObject { public let id: String public let type: PlannableType public let date: Date @@ -32,6 +39,11 @@ public struct TodoItemViewModel: Identifiable, Equatable, Comparable { public let color: Color public let icon: Image + public let plannableType: String + public let overrideId: String? + + @Published public var markDoneState: MarkDoneState = .notDone + public init?(_ plannable: Plannable, course: Course? = nil) { guard let date = plannable.date else { return nil } @@ -55,6 +67,9 @@ public struct TodoItemViewModel: Identifiable, Equatable, Comparable { self.color = plannable.color.asColor self.icon = plannable.icon.asImage + + self.plannableType = plannable.typeRaw + self.overrideId = plannable.plannerOverrideId } /// Helper function to determine the context name for a Todo item. @@ -86,7 +101,9 @@ public struct TodoItemViewModel: Identifiable, Equatable, Comparable { contextName: String, htmlURL: URL?, color: Color, - icon: Image + icon: Image, + plannableType: String = "assignment", + overrideId: String? = nil ) { self.id = id self.type = type @@ -100,6 +117,15 @@ public struct TodoItemViewModel: Identifiable, Equatable, Comparable { self.color = color self.icon = icon + + self.plannableType = plannableType + self.overrideId = overrideId + } + + // MARK: - Equatable + + public static func == (lhs: TodoItemViewModel, rhs: TodoItemViewModel) -> Bool { + lhs.id == rhs.id } // MARK: - Comparable From 60645182731bddb3f40256246408ff5e76a0ec5c Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 16 Oct 2025 09:29:07 +0200 Subject: [PATCH 29/49] Add checkbox and swipe-to-done to TodoListItemCell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Todos/View/TodoListItemCell.swift | 47 +++++++++++++++++-- .../Features/Todos/View/TodoListScreen.swift | 3 +- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index 6248c51fa9..9035bd31a4 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -22,8 +22,9 @@ struct TodoListItemCell: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.viewController) private var viewController - let item: TodoItemViewModel + @ObservedObject var item: TodoItemViewModel let onTap: (_ item: TodoItemViewModel, _ viewController: WeakViewController) -> Void + let onMarkAsDone: (_ item: TodoItemViewModel) -> Void var body: some View { VStack(spacing: 0) { @@ -32,24 +33,60 @@ struct TodoListItemCell: View { } label: { HStack(spacing: 0) { TodoItemContentView(item: item, isCompactLayout: false) - InstUI.DisclosureIndicator() + + checkboxButton .paddingStyle(.leading, .cellAccessoryPadding) - .accessibilityHidden(true) } .padding(.vertical, 8) .background(.backgroundLightest) } .accessibilityElement(children: .combine) + .onSwipe(trailing: swipeActions) } } + + @ViewBuilder + private var checkboxButton: some View { + Button { + onMarkAsDone(item) + } label: { + switch item.markDoneState { + case .notDone: + InstUI.Checkbox(isSelected: false) + case .loading: + ProgressView() + .tint(Color(Brand.shared.primary)) + case .done: + InstUI.Checkbox(isSelected: true) + } + } + .buttonStyle(.plain) + .frame(width: 44, height: 44) + .tint(Color(Brand.shared.primary)) + } + + private var swipeActions: [SwipeModel] { + [ + SwipeModel( + id: "done", + image: { Image.completeLine }, + action: { onMarkAsDone(item) }, + style: SwipeStyle( + background: .backgroundSuccess, + foregroundColor: .textLightest, + slotWidth: 60 + ) + ) + ] + } } #if DEBUG #Preview { VStack(spacing: 0) { - TodoListItemCell(item: .makeShortText(), onTap: { _, _ in }) - TodoListItemCell(item: .makeLongText(), onTap: { _, _ in }) + TodoListItemCell(item: .makeShortText(), onTap: { _, _ in }, onMarkAsDone: { _ in }) + TodoListItemCell(item: .makeLongText(), onTap: { _, _ in }, onMarkAsDone: { _ in }) } .background(Color.backgroundLightest) } diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index a3155e8faa..877210d09e 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -57,7 +57,8 @@ public struct TodoListScreen: View { ForEach(group.items) { item in TodoListItemCell( item: item, - onTap: viewModel.didTapItem + onTap: viewModel.didTapItem, + onMarkAsDone: { _ in } ) .padding(.leading, leadingPadding) From 4fe6b5f7b313e4a82ddeebe695dd26c1575128da Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 16 Oct 2025 09:39:14 +0200 Subject: [PATCH 30/49] Add mark-as-done logic to TodoListViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Features/Todos/View/TodoListScreen.swift | 2 +- .../Todos/ViewModel/TodoListViewModel.swift | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 877210d09e..84ae86fdc2 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -58,7 +58,7 @@ public struct TodoListScreen: View { TodoListItemCell( item: item, onTap: viewModel.didTapItem, - onMarkAsDone: { _ in } + onMarkAsDone: viewModel.markItemAsDone ) .padding(.leading, leadingPadding) diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index d5bc728fad..802b9a8208 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -35,6 +35,8 @@ public class TodoListViewModel: ObservableObject { private let interactor: TodoInteractor private let env: AppEnvironment private var subscriptions = Set() + /// Tracks cancellable timers for items in the done state waiting to be removed after 3 seconds + private var markDoneTimers: [String: AnyCancellable] = [:] init(interactor: TodoInteractor, env: AppEnvironment) { self.interactor = interactor @@ -90,4 +92,94 @@ public class TodoListViewModel: ObservableObject { plannerController?.selectDate(group.date) } } + + func markItemAsDone(_ item: TodoItemViewModel) { + if item.markDoneState == .notDone { + performMarkAsDone(item) + } else if item.markDoneState == .done { + performMarkAsUndone(item) + } + } + + private func performMarkAsDone(_ item: TodoItemViewModel) { + markDoneTimers[item.id]?.cancel() + item.markDoneState = .loading + + let useCase = MarkPlannableItemDone( + plannableId: item.id, + plannableType: item.plannableType, + overrideId: item.overrideId, + done: true + ) + + useCase.fetch(environment: env) { [weak self, weak item] _, _, error in + guard let self, let item else { return } + + if let error { + self.handleMarkAsDoneError(item, error) + } else { + self.handleMarkAsDoneSuccess(item) + } + } + } + + private func performMarkAsUndone(_ item: TodoItemViewModel) { + markDoneTimers[item.id]?.cancel() + markDoneTimers.removeValue(forKey: item.id) + item.markDoneState = .loading + + let useCase = MarkPlannableItemDone( + plannableId: item.id, + plannableType: item.plannableType, + overrideId: item.overrideId, + done: false + ) + + useCase.fetch(environment: env) { [weak self, weak item] _, _, error in + guard let self, let item else { return } + + if let error { + self.handleMarkAsUndoneError(item, error) + } else { + item.markDoneState = .notDone + } + } + } + + private func handleMarkAsDoneSuccess(_ item: TodoItemViewModel) { + item.markDoneState = .done + + let timer = Just(()) + .delay(for: .seconds(3), scheduler: DispatchQueue.main) + .sink { [weak self] in + self?.removeItem(item) + self?.markDoneTimers.removeValue(forKey: item.id) + } + + markDoneTimers[item.id] = timer + } + + private func handleMarkAsDoneError(_ item: TodoItemViewModel, _ error: Error) { + item.markDoneState = .notDone + // TODO: Show error snackbar in Phase 6 + print("Error marking as done: \(error)") + } + + private func handleMarkAsUndoneError(_ item: TodoItemViewModel, _ error: Error) { + item.markDoneState = .done + // TODO: Show error snackbar in Phase 6 + print("Error marking as undone: \(error)") + } + + private func removeItem(_ item: TodoItemViewModel) { + items = items.compactMap { group in + let filteredItems = group.items.filter { $0.id != item.id } + guard !filteredItems.isEmpty else { return nil } + return TodoGroupViewModel(date: group.date, items: filteredItems) + } + + if items.isEmpty { + state = .empty + } + } } From 7c4b968c8290680f44a8b55688777fbfe2de5dd6 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 16 Oct 2025 09:45:38 +0200 Subject: [PATCH 31/49] Add filtering for completed items in TodoInteractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Core/Core/Features/Todos/Model/TodoInteractor.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index af49097af1..f72bd8740b 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -60,10 +60,12 @@ public final class TodoInteractorLive: TodoInteractor { .getEntities(ignoreCache: ignoreCache, loadAllPages: true) .map { plannables in let coursesByCanvasContextIds = Dictionary(uniqueKeysWithValues: courses.map { ($0.canvasContextID, $0) }) - return plannables.compactMap { plannable in - let course = coursesByCanvasContextIds[plannable.canvasContextIDRaw ?? ""] - return TodoItemViewModel(plannable, course: course) - } + return plannables + .filter { !$0.isMarkedComplete && !$0.isSubmitted } + .compactMap { plannable in + let course = coursesByCanvasContextIds[plannable.canvasContextIDRaw ?? ""] + return TodoItemViewModel(plannable, course: course) + } } } .map { [weak todoGroupsSubject] (todos: [TodoItemViewModel]) in From 080618dd426c2642e33ad2aa304b53797b284c4a Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 16 Oct 2025 09:50:33 +0200 Subject: [PATCH 32/49] Add snackbar error handling for mark-as-done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Core/Core/Features/Todos/View/TodoListScreen.swift | 1 + Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 84ae86fdc2..5d0d7ccec0 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -48,6 +48,7 @@ public struct TodoListScreen: View { } .clipped() .navigationBarItems(leading: profileMenuButton) + .snackBar(viewModel: viewModel.snackBar) } @ViewBuilder diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index 802b9a8208..8ca9b2e549 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -31,6 +31,7 @@ public class TodoListViewModel: ObservableObject { subtitle: String(localized: "It looks like a great time to rest, relax, and recharge.", bundle: .core) ) ) + let snackBar = SnackBarViewModel() private let interactor: TodoInteractor private let env: AppEnvironment @@ -161,14 +162,12 @@ public class TodoListViewModel: ObservableObject { private func handleMarkAsDoneError(_ item: TodoItemViewModel, _ error: Error) { item.markDoneState = .notDone - // TODO: Show error snackbar in Phase 6 - print("Error marking as done: \(error)") + snackBar.showSnack(String(localized: "Failed to mark item as done", bundle: .core)) } private func handleMarkAsUndoneError(_ item: TodoItemViewModel, _ error: Error) { item.markDoneState = .done - // TODO: Show error snackbar in Phase 6 - print("Error marking as undone: \(error)") + snackBar.showSnack(String(localized: "Failed to mark item as undone", bundle: .core)) } private func removeItem(_ item: TodoItemViewModel) { From ac9aa6db533684638653885e4cc77a7329de3e1f Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 16 Oct 2025 10:52:16 +0200 Subject: [PATCH 33/49] Add unit tests for mark-as-done functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Todos/ViewModel/TodoItemViewModel.swift | 8 +- .../ViewModel/TodoListViewModelTests.swift | 163 ++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index e539232e3c..c9afca920c 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -147,7 +147,9 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO contextName: String = "FORC 101 or something longer to even show it in two lines", htmlURL: URL? = nil, color: Color = .red, - icon: Image = .assignmentLine + icon: Image = .assignmentLine, + plannableType: String = "assignment", + overrideId: String? = nil ) -> TodoItemViewModel { TodoItemViewModel( id: id, @@ -158,7 +160,9 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO contextName: contextName, htmlURL: htmlURL, color: color, - icon: icon + icon: icon, + plannableType: plannableType, + overrideId: overrideId ) } diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift index 93eabe5e71..7875746eb8 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift @@ -260,4 +260,167 @@ class TodoListViewModelTests: CoreTestCase { XCTAssert(router.lastRoutedTo("/profile")) XCTAssertEqual(router.calls.last?.2.isModal, true) } + + func test_markItemAsDone_startsInNotDoneState() { + // GIVEN + let item = TodoItemViewModel.make(id: "1") + + // THEN + XCTAssertEqual(item.markDoneState, .notDone) + } + + func test_markItemAsDone_onSuccess_changesStateToDone() { + // GIVEN + Plannable.save(APIPlannable.make(plannable_id: ID("1")), userId: nil, in: databaseClient) + api.mock(CreatePlannerOverrideRequest(body: .init(plannable_type: "assignment", plannable_id: "1", marked_complete: true)), value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + + // WHEN + testee.markItemAsDone(item) + + // THEN + waitUntil(shouldFail: true) { item.markDoneState == .done } + } + + func test_markItemAsDone_onError_changesStateBackToNotDone() { + // GIVEN + api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: true)), error: NSError.internalError()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + + // WHEN + testee.markItemAsDone(item) + + // THEN + waitUntil(shouldFail: true) { item.markDoneState == .notDone } + } + + func test_markItemAsDone_onError_showsSnackBar() { + // GIVEN + api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: true)), error: NSError.internalError()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + + // WHEN + testee.markItemAsDone(item) + + // THEN + waitUntil(shouldFail: true) { testee.snackBar.visibleSnack != nil } + } + + func test_markItemAsDone_removesItemAfterThreeSeconds() { + // GIVEN + Plannable.save(APIPlannable.make(plannable_id: ID("1")), userId: nil, in: databaseClient) + api.mock(CreatePlannerOverrideRequest(body: .init(plannable_type: "assignment", plannable_id: "1", marked_complete: true)), value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let group = TodoGroupViewModel(date: Date(), items: [item]) + testee.items = [group] + + // WHEN + testee.markItemAsDone(item) + + // THEN + waitUntil(shouldFail: true) { item.markDoneState == .done } + XCTAssertEqual(testee.items.count, 1) + XCTAssertEqual(testee.items.first?.items.count, 1) + + waitUntil(4, shouldFail: true) { testee.items.count == 0 } + } + + func test_markItemAsDone_whileDone_marksAsUndone() { + // GIVEN + Plannable.save(APIPlannable.make(planner_override: .make(id: "override-1", marked_complete: true), plannable_id: ID("1")), userId: nil, in: databaseClient) + api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: false)), value: APINoContent()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + item.markDoneState = .done + + // WHEN + testee.markItemAsDone(item) + + // THEN + waitUntil(shouldFail: true) { item.markDoneState == .notDone } + } + + func test_markItemAsDone_undoBeforeRemoval_cancelsTimer() { + // GIVEN + Plannable.save(APIPlannable.make(plannable_id: ID("1")), userId: nil, in: databaseClient) + api.mock(CreatePlannerOverrideRequest(body: .init(plannable_type: "assignment", plannable_id: "1", marked_complete: true)), value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) + api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: false)), value: APINoContent()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + let group = TodoGroupViewModel(date: Date(), items: [item]) + testee.items = [group] + + // WHEN + testee.markItemAsDone(item) + waitUntil(shouldFail: true) { item.markDoneState == .done } + + testee.markItemAsDone(item) + waitUntil(shouldFail: true) { item.markDoneState == .notDone } + + // THEN + waitUntil(4, shouldFail: true) { testee.items.count == 1 && testee.items.first?.items.count == 1 } + } + + func test_markAsUndone_onError_changesStateBackToDone() { + // GIVEN + api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: false)), error: NSError.internalError()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + item.markDoneState = .done + + // WHEN + testee.markItemAsDone(item) + + // THEN + waitUntil(shouldFail: true) { item.markDoneState == .done } + } + + func test_markAsUndone_onError_showsSnackBar() { + // GIVEN + api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: false)), error: NSError.internalError()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + item.markDoneState = .done + + // WHEN + testee.markItemAsDone(item) + + // THEN + waitUntil(shouldFail: true) { testee.snackBar.visibleSnack != nil } + } + + func test_removeItem_removesEmptyGroups() { + // GIVEN + let item1 = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let item2 = TodoItemViewModel.make(id: "2") + let group1 = TodoGroupViewModel(date: Date(), items: [item1]) + let group2 = TodoGroupViewModel(date: Date().addingTimeInterval(86400), items: [item2]) + testee.items = [group1, group2] + Plannable.save(APIPlannable.make(plannable_id: ID("1")), userId: nil, in: databaseClient) + api.mock(CreatePlannerOverrideRequest(body: .init(plannable_type: "assignment", plannable_id: "1", marked_complete: true)), value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) + + // WHEN + testee.markItemAsDone(item1) + waitUntil(shouldFail: true) { item1.markDoneState == .done } + + // THEN + waitUntil(4, shouldFail: true) { + testee.items.count == 1 && testee.items.first?.items.first?.id == "2" + } + } + + func test_removeItem_setsStateToEmpty_whenLastItemRemoved() { + // GIVEN + Plannable.save(APIPlannable.make(plannable_id: ID("1")), userId: nil, in: databaseClient) + api.mock(CreatePlannerOverrideRequest(body: .init(plannable_type: "assignment", plannable_id: "1", marked_complete: true)), value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let group = TodoGroupViewModel(date: Date(), items: [item]) + testee.items = [group] + testee.state = .data + + // WHEN + testee.markItemAsDone(item) + waitUntil(shouldFail: true) { item.markDoneState == .done } + + // THEN + waitUntil(4, shouldFail: true) { + testee.items.count == 0 && testee.state == .empty + } + } } From 69bba47f1b87eba6ea9d9722ee5841a276f043bc Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 16 Oct 2025 10:56:55 +0200 Subject: [PATCH 34/49] Add accessibility identifier for checkbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Core/Core/Features/Todos/View/TodoListItemCell.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index 9035bd31a4..5c9b11fb26 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -63,6 +63,7 @@ struct TodoListItemCell: View { .buttonStyle(.plain) .frame(width: 44, height: 44) .tint(Color(Brand.shared.primary)) + .identifier("to-do.list.\(item.id).checkbox") } private var swipeActions: [SwipeModel] { From d3626ea87eb2f17a3f534de17ec73c9545583dc8 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 16 Oct 2025 12:15:33 +0200 Subject: [PATCH 35/49] Fix refresh issues. Update async tests. --- .../Todos/ViewModel/TodoItemViewModel.swift | 11 +++- .../Todos/ViewModel/TodoListViewModel.swift | 41 ++++++++------ Core/Core/Resources/Localizable.xcstrings | 6 ++ .../ViewModel/TodoItemViewModelTests.swift | 35 ++++++++++++ .../ViewModel/TodoListViewModelTests.swift | 56 ++++++++++++------- 5 files changed, 112 insertions(+), 37 deletions(-) diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index c9afca920c..f6c8accbad 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -70,6 +70,7 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO self.plannableType = plannable.typeRaw self.overrideId = plannable.plannerOverrideId + self.markDoneState = plannable.isMarkedComplete ? .done : .notDone } /// Helper function to determine the context name for a Todo item. @@ -125,7 +126,15 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO // MARK: - Equatable public static func == (lhs: TodoItemViewModel, rhs: TodoItemViewModel) -> Bool { - lhs.id == rhs.id + lhs.id == rhs.id && + lhs.type == rhs.type && + lhs.date == rhs.date && + lhs.title == rhs.title && + lhs.subtitle == rhs.subtitle && + lhs.contextName == rhs.contextName && + lhs.htmlURL == rhs.htmlURL && + lhs.color == rhs.color && + lhs.markDoneState == rhs.markDoneState } // MARK: - Comparable diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index 8ca9b2e549..e0e93d76f1 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -19,6 +19,7 @@ import Foundation import Combine import CombineExt +import CombineSchedulers import UIKit public class TodoListViewModel: ObservableObject { @@ -35,13 +36,19 @@ public class TodoListViewModel: ObservableObject { private let interactor: TodoInteractor private let env: AppEnvironment + private let scheduler: AnySchedulerOf private var subscriptions = Set() /// Tracks cancellable timers for items in the done state waiting to be removed after 3 seconds private var markDoneTimers: [String: AnyCancellable] = [:] - init(interactor: TodoInteractor, env: AppEnvironment) { + init( + interactor: TodoInteractor, + env: AppEnvironment, + scheduler: AnySchedulerOf = .main + ) { self.interactor = interactor self.env = env + self.scheduler = scheduler interactor.todoGroups .assign(to: \.items, on: self, ownership: .weak) @@ -113,15 +120,16 @@ public class TodoListViewModel: ObservableObject { done: true ) - useCase.fetch(environment: env) { [weak self, weak item] _, _, error in - guard let self, let item else { return } - - if let error { - self.handleMarkAsDoneError(item, error) - } else { + useCase.fetchWithFuture(environment: env) + .receive(on: scheduler) + .sinkFailureOrValue { [weak self, weak item] error in + guard let item else { return } + self?.handleMarkAsDoneError(item, error) + } receiveValue: { [weak item] _ in + guard let item else { return } self.handleMarkAsDoneSuccess(item) } - } + .store(in: &subscriptions) } private func performMarkAsUndone(_ item: TodoItemViewModel) { @@ -136,22 +144,23 @@ public class TodoListViewModel: ObservableObject { done: false ) - useCase.fetch(environment: env) { [weak self, weak item] _, _, error in - guard let self, let item else { return } - - if let error { - self.handleMarkAsUndoneError(item, error) - } else { + useCase.fetchWithFuture(environment: env) + .receive(on: scheduler) + .sinkFailureOrValue { [weak self, weak item] error in + guard let item else { return } + self?.handleMarkAsUndoneError(item, error) + } receiveValue: { [weak item] _ in + guard let item else { return } item.markDoneState = .notDone } - } + .store(in: &subscriptions) } private func handleMarkAsDoneSuccess(_ item: TodoItemViewModel) { item.markDoneState = .done let timer = Just(()) - .delay(for: .seconds(3), scheduler: DispatchQueue.main) + .delay(for: .seconds(3), scheduler: scheduler) .sink { [weak self] in self?.removeItem(item) self?.markDoneTimers.removeValue(forKey: item.id) diff --git a/Core/Core/Resources/Localizable.xcstrings b/Core/Core/Resources/Localizable.xcstrings index 1f1cc1e7d2..a313012efc 100644 --- a/Core/Core/Resources/Localizable.xcstrings +++ b/Core/Core/Resources/Localizable.xcstrings @@ -148469,6 +148469,12 @@ } } } + }, + "Failed to mark item as done" : { + + }, + "Failed to mark item as undone" : { + }, "Failed to publish all Modules and all Items" : { "localizations" : { diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift index dec93f292b..f74020e244 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift @@ -272,6 +272,41 @@ class TodoItemViewModelTests: CoreTestCase { XCTAssertEqual(todoItem.date, specificDate) } + func test_markDoneState_initializesToNotDone_whenPlannableIsNotComplete() { + // GIVEN + let plannable = Plannable.save( + APIPlannable.make(plannable_id: ID("1")), + userId: nil, + in: databaseClient + ) + + // WHEN + let todoItem = TodoItemViewModel(plannable) + + // THEN + XCTAssertNotNil(todoItem) + XCTAssertEqual(todoItem?.markDoneState, .notDone) + } + + func test_markDoneState_initializesToDone_whenPlannableIsComplete() { + // GIVEN + let plannable = Plannable.save( + APIPlannable.make( + planner_override: .make(id: "override-1", marked_complete: true), + plannable_id: ID("1") + ), + userId: nil, + in: databaseClient + ) + + // WHEN + let todoItem = TodoItemViewModel(plannable) + + // THEN + XCTAssertNotNil(todoItem) + XCTAssertEqual(todoItem?.markDoneState, .done) + } + // MARK: - Helpers private func makePlannable( diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift index 7875746eb8..f0647f97ec 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift @@ -20,18 +20,20 @@ import TestsFoundation import XCTest import Combine +import CombineSchedulers class TodoListViewModelTests: CoreTestCase { private var interactor: TodoInteractorMock! private var testee: TodoListViewModel! + private let testScheduler: TestSchedulerOf = DispatchQueue.test // MARK: - Setup and teardown override func setUp() { super.setUp() interactor = .init() - testee = .init(interactor: interactor, env: environment) + testee = .init(interactor: interactor, env: environment, scheduler: testScheduler.eraseToAnyScheduler()) } override func tearDown() { @@ -277,9 +279,10 @@ class TodoListViewModelTests: CoreTestCase { // WHEN testee.markItemAsDone(item) + testScheduler.advance() // THEN - waitUntil(shouldFail: true) { item.markDoneState == .done } + XCTAssertEqual(item.markDoneState, .done) } func test_markItemAsDone_onError_changesStateBackToNotDone() { @@ -289,9 +292,10 @@ class TodoListViewModelTests: CoreTestCase { // WHEN testee.markItemAsDone(item) + testScheduler.advance() // THEN - waitUntil(shouldFail: true) { item.markDoneState == .notDone } + XCTAssertEqual(item.markDoneState, .notDone) } func test_markItemAsDone_onError_showsSnackBar() { @@ -301,9 +305,10 @@ class TodoListViewModelTests: CoreTestCase { // WHEN testee.markItemAsDone(item) + testScheduler.advance() // THEN - waitUntil(shouldFail: true) { testee.snackBar.visibleSnack != nil } + XCTAssertNotNil(testee.snackBar.visibleSnack) } func test_markItemAsDone_removesItemAfterThreeSeconds() { @@ -316,13 +321,15 @@ class TodoListViewModelTests: CoreTestCase { // WHEN testee.markItemAsDone(item) + testScheduler.advance() // THEN - waitUntil(shouldFail: true) { item.markDoneState == .done } + XCTAssertEqual(item.markDoneState, .done) XCTAssertEqual(testee.items.count, 1) XCTAssertEqual(testee.items.first?.items.count, 1) - waitUntil(4, shouldFail: true) { testee.items.count == 0 } + testScheduler.advance(by: .seconds(3)) + XCTAssertEqual(testee.items.count, 0) } func test_markItemAsDone_whileDone_marksAsUndone() { @@ -334,9 +341,10 @@ class TodoListViewModelTests: CoreTestCase { // WHEN testee.markItemAsDone(item) + testScheduler.advance() // THEN - waitUntil(shouldFail: true) { item.markDoneState == .notDone } + XCTAssertEqual(item.markDoneState, .notDone) } func test_markItemAsDone_undoBeforeRemoval_cancelsTimer() { @@ -350,13 +358,17 @@ class TodoListViewModelTests: CoreTestCase { // WHEN testee.markItemAsDone(item) - waitUntil(shouldFail: true) { item.markDoneState == .done } + testScheduler.advance() + XCTAssertEqual(item.markDoneState, .done) testee.markItemAsDone(item) - waitUntil(shouldFail: true) { item.markDoneState == .notDone } + testScheduler.advance() + XCTAssertEqual(item.markDoneState, .notDone) // THEN - waitUntil(4, shouldFail: true) { testee.items.count == 1 && testee.items.first?.items.count == 1 } + testScheduler.advance(by: .seconds(3)) + XCTAssertEqual(testee.items.count, 1) + XCTAssertEqual(testee.items.first?.items.count, 1) } func test_markAsUndone_onError_changesStateBackToDone() { @@ -367,9 +379,10 @@ class TodoListViewModelTests: CoreTestCase { // WHEN testee.markItemAsDone(item) + testScheduler.advance() // THEN - waitUntil(shouldFail: true) { item.markDoneState == .done } + XCTAssertEqual(item.markDoneState, .done) } func test_markAsUndone_onError_showsSnackBar() { @@ -380,9 +393,10 @@ class TodoListViewModelTests: CoreTestCase { // WHEN testee.markItemAsDone(item) + testScheduler.advance() // THEN - waitUntil(shouldFail: true) { testee.snackBar.visibleSnack != nil } + XCTAssertNotNil(testee.snackBar.visibleSnack) } func test_removeItem_removesEmptyGroups() { @@ -397,12 +411,13 @@ class TodoListViewModelTests: CoreTestCase { // WHEN testee.markItemAsDone(item1) - waitUntil(shouldFail: true) { item1.markDoneState == .done } + testScheduler.advance() + XCTAssertEqual(item1.markDoneState, .done) // THEN - waitUntil(4, shouldFail: true) { - testee.items.count == 1 && testee.items.first?.items.first?.id == "2" - } + testScheduler.advance(by: .seconds(3)) + XCTAssertEqual(testee.items.count, 1) + XCTAssertEqual(testee.items.first?.items.first?.id, "2") } func test_removeItem_setsStateToEmpty_whenLastItemRemoved() { @@ -416,11 +431,12 @@ class TodoListViewModelTests: CoreTestCase { // WHEN testee.markItemAsDone(item) - waitUntil(shouldFail: true) { item.markDoneState == .done } + testScheduler.advance() + XCTAssertEqual(item.markDoneState, .done) // THEN - waitUntil(4, shouldFail: true) { - testee.items.count == 0 && testee.state == .empty - } + testScheduler.advance(by: .seconds(3)) + XCTAssertEqual(testee.items.count, 0) + XCTAssertEqual(testee.state, .empty) } } From db4fda54b1fead8bba4fc38caef172399ce00d83 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 16 Oct 2025 17:13:21 +0200 Subject: [PATCH 36/49] Refactor mark-as-done to use interactor pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move API logic from view model to TodoInteractor - Inject router dependency into TodoListViewModel - Prevent multiple taps from firing duplicate API requests - Update todo badge counter when marking items done/undone - Add comprehensive unit tests for new behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Features/Todos/Model/TodoInteractor.swift | 29 +++++ Core/Core/Features/Todos/TodoAssembly.swift | 2 +- .../Features/Todos/View/TodoListScreen.swift | 10 +- .../Todos/ViewModel/TodoItemViewModel.swift | 2 +- .../Todos/ViewModel/TodoListViewModel.swift | 51 +++++---- .../Todos/Model/TodoInteractorMock.swift | 23 ++++ .../ViewModel/TodoListViewModelTests.swift | 102 ++++++++++++++---- 7 files changed, 170 insertions(+), 49 deletions(-) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index f72bd8740b..fd62222b2a 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -22,6 +22,7 @@ import Combine public protocol TodoInteractor { var todoGroups: AnyPublisher<[TodoGroupViewModel], Never> { get } func refresh(ignoreCache: Bool) -> AnyPublisher + func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher } public final class TodoInteractorLive: TodoInteractor { @@ -79,6 +80,30 @@ public final class TodoInteractorLive: TodoInteractor { .eraseToAnyPublisher() } + public func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher { + let useCase = MarkPlannableItemDone( + plannableId: item.id, + plannableType: item.plannableType, + overrideId: item.overrideId, + done: done + ) + + return useCase.fetchWithFuture(environment: env) + .map { [weak self] _ in + self?.updateOverrideId(for: item) + return () + } + .eraseToAnyPublisher() + } + + private func updateOverrideId(for item: TodoItemViewModel) { + let scope = Scope.plannable(id: item.id) + if let plannable: Plannable = env.database.viewContext.fetch(scope: scope).first, + let overrideId = plannable.plannerOverrideId { + item.overrideId = overrideId + } + } + private static func groupTodosByDay(_ todos: [TodoItemViewModel]) -> [TodoGroupViewModel] { // Group todos by day using existing Canvas extension let groupedDict = Dictionary(grouping: todos) { todo in @@ -126,6 +151,10 @@ public final class TodoInteractorPreview: TodoInteractor { public func refresh(ignoreCache: Bool) -> AnyPublisher { Publishers.typedJust(()) } + + public func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher { + Publishers.typedJust(()) + } } #endif diff --git a/Core/Core/Features/Todos/TodoAssembly.swift b/Core/Core/Features/Todos/TodoAssembly.swift index ad768bd050..f1d464058a 100644 --- a/Core/Core/Features/Todos/TodoAssembly.swift +++ b/Core/Core/Features/Todos/TodoAssembly.swift @@ -21,7 +21,7 @@ import SwiftUI public struct TodoAssembly { public static func makeTodoListViewController(env: AppEnvironment) -> UIViewController { let interactor = TodoInteractorLive(env: env) - let model = TodoListViewModel(interactor: interactor, env: env) + let model = TodoListViewModel(interactor: interactor, router: env.router) let todoVC = CoreHostingController(TodoListScreen(viewModel: model)) todoVC.navigationBarStyle = .global todoVC.navigationItem.titleView = Brand.shared.headerImageView() diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 5d0d7ccec0..5a087c2285 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -62,6 +62,10 @@ public struct TodoListScreen: View { onMarkAsDone: viewModel.markItemAsDone ) .padding(.leading, leadingPadding) + .transition(.asymmetric( + insertion: .identity, + removal: .move(edge: .leading).combined(with: .opacity) + )) let isLastItemInGroup = (group.items.last == item) @@ -109,12 +113,14 @@ public struct TodoListScreen: View { #if DEBUG #Preview { - let viewModel = TodoListViewModel(interactor: TodoInteractorPreview(), env: PreviewEnvironment()) + let env = PreviewEnvironment() + let viewModel = TodoListViewModel(interactor: TodoInteractorPreview(), router: env.router) TodoListScreen(viewModel: viewModel) } #Preview("Empty State") { - let viewModel = TodoListViewModel(interactor: TodoInteractorPreview(todoGroups: []), env: PreviewEnvironment()) + let env = PreviewEnvironment() + let viewModel = TodoListViewModel(interactor: TodoInteractorPreview(todoGroups: []), router: env.router) TodoListScreen(viewModel: viewModel) } diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index f6c8accbad..f17233c3e9 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -40,7 +40,7 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO public let icon: Image public let plannableType: String - public let overrideId: String? + public var overrideId: String? @Published public var markDoneState: MarkDoneState = .notDone diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index e0e93d76f1..0a23b0a774 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -20,7 +20,7 @@ import Foundation import Combine import CombineExt import CombineSchedulers -import UIKit +import SwiftUI public class TodoListViewModel: ObservableObject { @Published var items: [TodoGroupViewModel] = [] @@ -35,7 +35,7 @@ public class TodoListViewModel: ObservableObject { let snackBar = SnackBarViewModel() private let interactor: TodoInteractor - private let env: AppEnvironment + private let router: Router private let scheduler: AnySchedulerOf private var subscriptions = Set() /// Tracks cancellable timers for items in the done state waiting to be removed after 3 seconds @@ -43,11 +43,11 @@ public class TodoListViewModel: ObservableObject { init( interactor: TodoInteractor, - env: AppEnvironment, + router: Router, scheduler: AnySchedulerOf = .main ) { self.interactor = interactor - self.env = env + self.router = router self.scheduler = scheduler interactor.todoGroups @@ -74,20 +74,20 @@ public class TodoListViewModel: ObservableObject { switch item.type { case .planner_note: let vc = PlannerAssembly.makeToDoDetailsViewController(plannableId: item.id) - env.router.show(vc, from: viewController, options: .detail) + router.show(vc, from: viewController, options: .detail) case .calendar_event: let vc = PlannerAssembly.makeEventDetailsViewController(eventId: item.id) - env.router.show(vc, from: viewController, options: .detail) + router.show(vc, from: viewController, options: .detail) default: guard let url = item.htmlURL else { return } let to = url.appendingOrigin("todo") - env.router.route(to: to, from: viewController, options: .detail) + router.route(to: to, from: viewController, options: .detail) return } } func openProfile(_ viewController: WeakViewController) { - env.router.route(to: "/profile", from: viewController, options: .modal()) + router.route(to: "/profile", from: viewController, options: .modal()) } func didTapDayHeader(_ group: TodoGroupViewModel, viewController: WeakViewController) { @@ -102,6 +102,10 @@ public class TodoListViewModel: ObservableObject { } func markItemAsDone(_ item: TodoItemViewModel) { + if item.markDoneState == .loading { + return + } + if item.markDoneState == .notDone { performMarkAsDone(item) } else if item.markDoneState == .done { @@ -113,20 +117,13 @@ public class TodoListViewModel: ObservableObject { markDoneTimers[item.id]?.cancel() item.markDoneState = .loading - let useCase = MarkPlannableItemDone( - plannableId: item.id, - plannableType: item.plannableType, - overrideId: item.overrideId, - done: true - ) - - useCase.fetchWithFuture(environment: env) + interactor.markItemAsDone(item, done: true) .receive(on: scheduler) .sinkFailureOrValue { [weak self, weak item] error in guard let item else { return } self?.handleMarkAsDoneError(item, error) - } receiveValue: { [weak item] _ in - guard let item else { return } + } receiveValue: { [weak self, weak item] _ in + guard let self, let item else { return } self.handleMarkAsDoneSuccess(item) } .store(in: &subscriptions) @@ -137,14 +134,7 @@ public class TodoListViewModel: ObservableObject { markDoneTimers.removeValue(forKey: item.id) item.markDoneState = .loading - let useCase = MarkPlannableItemDone( - plannableId: item.id, - plannableType: item.plannableType, - overrideId: item.overrideId, - done: false - ) - - useCase.fetchWithFuture(environment: env) + interactor.markItemAsDone(item, done: false) .receive(on: scheduler) .sinkFailureOrValue { [weak self, weak item] error in guard let item else { return } @@ -152,6 +142,7 @@ public class TodoListViewModel: ObservableObject { } receiveValue: { [weak item] _ in guard let item else { return } item.markDoneState = .notDone + TabBarBadgeCounts.todoListCount += 1 } .store(in: &subscriptions) } @@ -159,10 +150,16 @@ public class TodoListViewModel: ObservableObject { private func handleMarkAsDoneSuccess(_ item: TodoItemViewModel) { item.markDoneState = .done + if TabBarBadgeCounts.todoListCount > 0 { + TabBarBadgeCounts.todoListCount -= 1 + } + let timer = Just(()) .delay(for: .seconds(3), scheduler: scheduler) .sink { [weak self] in - self?.removeItem(item) + withAnimation { + self?.removeItem(item) + } self?.markDoneTimers.removeValue(forKey: item.id) } diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift index 379524a16f..ac573eec6a 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift @@ -30,6 +30,12 @@ final class TodoInteractorMock: TodoInteractor { var lastIgnoreCache = false var refreshResult: Result = .success(()) + var markItemAsDoneCalled = false + var markItemAsDoneCallCount = 0 + var lastMarkAsDoneItem: TodoItemViewModel? + var lastMarkAsDoneDone: Bool? + var markItemAsDoneResult: Result = .success(()) + func refresh(ignoreCache: Bool) -> AnyPublisher { refreshCalled = true refreshCallCount += 1 @@ -45,4 +51,21 @@ final class TodoInteractorMock: TodoInteractor { .eraseToAnyPublisher() } } + + func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher { + markItemAsDoneCalled = true + markItemAsDoneCallCount += 1 + lastMarkAsDoneItem = item + lastMarkAsDoneDone = done + + switch markItemAsDoneResult { + case .success: + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + case .failure(let error): + return Fail(error: error) + .eraseToAnyPublisher() + } + } } diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift index f0647f97ec..41d6a83863 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift @@ -33,7 +33,7 @@ class TodoListViewModelTests: CoreTestCase { override func setUp() { super.setUp() interactor = .init() - testee = .init(interactor: interactor, env: environment, scheduler: testScheduler.eraseToAnyScheduler()) + testee = .init(interactor: interactor, router: router, scheduler: testScheduler.eraseToAnyScheduler()) } override func tearDown() { @@ -273,8 +273,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDone_onSuccess_changesStateToDone() { // GIVEN - Plannable.save(APIPlannable.make(plannable_id: ID("1")), userId: nil, in: databaseClient) - api.mock(CreatePlannerOverrideRequest(body: .init(plannable_type: "assignment", plannable_id: "1", marked_complete: true)), value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) + interactor.markItemAsDoneResult = .success(()) let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") // WHEN @@ -283,11 +282,14 @@ class TodoListViewModelTests: CoreTestCase { // THEN XCTAssertEqual(item.markDoneState, .done) + XCTAssertTrue(interactor.markItemAsDoneCalled) + XCTAssertEqual(interactor.lastMarkAsDoneItem?.id, "1") + XCTAssertEqual(interactor.lastMarkAsDoneDone, true) } func test_markItemAsDone_onError_changesStateBackToNotDone() { // GIVEN - api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: true)), error: NSError.internalError()) + interactor.markItemAsDoneResult = .failure(NSError.internalError()) let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") // WHEN @@ -300,7 +302,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDone_onError_showsSnackBar() { // GIVEN - api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: true)), error: NSError.internalError()) + interactor.markItemAsDoneResult = .failure(NSError.internalError()) let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") // WHEN @@ -313,8 +315,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDone_removesItemAfterThreeSeconds() { // GIVEN - Plannable.save(APIPlannable.make(plannable_id: ID("1")), userId: nil, in: databaseClient) - api.mock(CreatePlannerOverrideRequest(body: .init(plannable_type: "assignment", plannable_id: "1", marked_complete: true)), value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) + interactor.markItemAsDoneResult = .success(()) let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") let group = TodoGroupViewModel(date: Date(), items: [item]) testee.items = [group] @@ -334,8 +335,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDone_whileDone_marksAsUndone() { // GIVEN - Plannable.save(APIPlannable.make(planner_override: .make(id: "override-1", marked_complete: true), plannable_id: ID("1")), userId: nil, in: databaseClient) - api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: false)), value: APINoContent()) + interactor.markItemAsDoneResult = .success(()) let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") item.markDoneState = .done @@ -345,13 +345,13 @@ class TodoListViewModelTests: CoreTestCase { // THEN XCTAssertEqual(item.markDoneState, .notDone) + XCTAssertTrue(interactor.markItemAsDoneCalled) + XCTAssertEqual(interactor.lastMarkAsDoneDone, false) } func test_markItemAsDone_undoBeforeRemoval_cancelsTimer() { // GIVEN - Plannable.save(APIPlannable.make(plannable_id: ID("1")), userId: nil, in: databaseClient) - api.mock(CreatePlannerOverrideRequest(body: .init(plannable_type: "assignment", plannable_id: "1", marked_complete: true)), value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) - api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: false)), value: APINoContent()) + interactor.markItemAsDoneResult = .success(()) let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") let group = TodoGroupViewModel(date: Date(), items: [item]) testee.items = [group] @@ -373,7 +373,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markAsUndone_onError_changesStateBackToDone() { // GIVEN - api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: false)), error: NSError.internalError()) + interactor.markItemAsDoneResult = .failure(NSError.internalError()) let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") item.markDoneState = .done @@ -387,7 +387,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markAsUndone_onError_showsSnackBar() { // GIVEN - api.mock(UpdatePlannerOverrideRequest(overrideId: "override-1", body: .init(marked_complete: false)), error: NSError.internalError()) + interactor.markItemAsDoneResult = .failure(NSError.internalError()) let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") item.markDoneState = .done @@ -401,13 +401,12 @@ class TodoListViewModelTests: CoreTestCase { func test_removeItem_removesEmptyGroups() { // GIVEN + interactor.markItemAsDoneResult = .success(()) let item1 = TodoItemViewModel.make(id: "1", plannableType: "assignment") let item2 = TodoItemViewModel.make(id: "2") let group1 = TodoGroupViewModel(date: Date(), items: [item1]) let group2 = TodoGroupViewModel(date: Date().addingTimeInterval(86400), items: [item2]) testee.items = [group1, group2] - Plannable.save(APIPlannable.make(plannable_id: ID("1")), userId: nil, in: databaseClient) - api.mock(CreatePlannerOverrideRequest(body: .init(plannable_type: "assignment", plannable_id: "1", marked_complete: true)), value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) // WHEN testee.markItemAsDone(item1) @@ -422,8 +421,7 @@ class TodoListViewModelTests: CoreTestCase { func test_removeItem_setsStateToEmpty_whenLastItemRemoved() { // GIVEN - Plannable.save(APIPlannable.make(plannable_id: ID("1")), userId: nil, in: databaseClient) - api.mock(CreatePlannerOverrideRequest(body: .init(plannable_type: "assignment", plannable_id: "1", marked_complete: true)), value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) + interactor.markItemAsDoneResult = .success(()) let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") let group = TodoGroupViewModel(date: Date(), items: [item]) testee.items = [group] @@ -439,4 +437,72 @@ class TodoListViewModelTests: CoreTestCase { XCTAssertEqual(testee.items.count, 0) XCTAssertEqual(testee.state, .empty) } + + func test_markItemAsDone_whileLoading_ignoresAdditionalTaps() { + // GIVEN + interactor.markItemAsDoneResult = .success(()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + + // WHEN + testee.markItemAsDone(item) + XCTAssertEqual(item.markDoneState, .loading) + XCTAssertEqual(interactor.markItemAsDoneCallCount, 1) + + testee.markItemAsDone(item) + testee.markItemAsDone(item) + testee.markItemAsDone(item) + + // THEN + XCTAssertEqual(interactor.markItemAsDoneCallCount, 1) + XCTAssertEqual(item.markDoneState, .loading) + + testScheduler.advance() + XCTAssertEqual(item.markDoneState, .done) + } + + func test_markItemAsDone_decrementsBadgeCount() { + // GIVEN + TabBarBadgeCounts.todoListCount = 5 + interactor.markItemAsDoneResult = .success(()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + + // WHEN + testee.markItemAsDone(item) + testScheduler.advance() + + // THEN + XCTAssertEqual(TabBarBadgeCounts.todoListCount, 4) + XCTAssertEqual(item.markDoneState, .done) + } + + func test_markItemAsUndone_incrementsBadgeCount() { + // GIVEN + TabBarBadgeCounts.todoListCount = 3 + interactor.markItemAsDoneResult = .success(()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + item.markDoneState = .done + + // WHEN + testee.markItemAsDone(item) + testScheduler.advance() + + // THEN + XCTAssertEqual(TabBarBadgeCounts.todoListCount, 4) + XCTAssertEqual(item.markDoneState, .notDone) + } + + func test_markItemAsDone_doesNotDecrementBadgeCountBelowZero() { + // GIVEN + TabBarBadgeCounts.todoListCount = 0 + interactor.markItemAsDoneResult = .success(()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + + // WHEN + testee.markItemAsDone(item) + testScheduler.advance() + + // THEN + XCTAssertEqual(TabBarBadgeCounts.todoListCount, 0) + XCTAssertEqual(item.markDoneState, .done) + } } From e613c3134d1817376e0b0455362e59f6841a7cb2 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 21 Oct 2025 09:52:01 +0200 Subject: [PATCH 37/49] Add swipe gesture. --- .../SwiftUIViews/FullSwipeAction.swift | 153 ++++++++++++++++++ .../SwiftUIViews/View+MeasuringSize.swift | 17 ++ .../Todos/View/TodoListItemCell.swift | 34 ++-- .../Features/Todos/View/TodoListScreen.swift | 7 +- .../Todos/ViewModel/TodoItemViewModel.swift | 51 +++--- .../Todos/Model/TodoInteractorLiveTests.swift | 1 + 6 files changed, 220 insertions(+), 43 deletions(-) create mode 100644 Core/Core/Common/CommonUI/SwiftUIViews/FullSwipeAction.swift diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/FullSwipeAction.swift b/Core/Core/Common/CommonUI/SwiftUIViews/FullSwipeAction.swift new file mode 100644 index 0000000000..1632737f52 --- /dev/null +++ b/Core/Core/Common/CommonUI/SwiftUIViews/FullSwipeAction.swift @@ -0,0 +1,153 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import SwiftUI + +public extension View { + func fullSwipeAction( + backgroundColor: Color, + onSwipe: @escaping () -> Void, + @ViewBuilder actionView: @escaping () -> ActionView + ) -> some View { + modifier(FullSwipeActionModifier( + backgroundColor: backgroundColor, + onSwipe: onSwipe, + actionView: actionView + )) + } +} + +private struct FullSwipeActionModifier: ViewModifier { + let backgroundColor: Color + let onSwipe: () -> Void + let actionView: () -> ActionView + + @State private var cellContentOffset: CGFloat = 0 + @State private var cellWidth: CGFloat = 0 + @State private var actionViewWidth: CGFloat = 0 + /// The point based horizontal offset that must be reached by the drag gesture to trigger the action. + @State private var actionThreshold: CGFloat = 0 + /// Becomes true, if dragging goes beyond `actionThreshold`. If grad is ended while this is true the swipe action will be performed. + @State private var isActionThresholdReached = false + @State private var actionViewOffset: CGFloat = 0 + + private let hapticGenerator = UIImpactFeedbackGenerator(style: .medium) + + func body(content: Content) -> some View { + ZStack(alignment: .trailing) { + swipeBackground + content.offset(x: cellContentOffset).clipped() + } + .onWidthChange { width in + cellWidth = width + actionThreshold = cellWidth * 0.8 + } + .contentShape(Rectangle()) + // If this is a simple gesture and the cell is a button then swiping won't work + .simultaneousGesture( + DragGesture() + .onChanged(handleDragChanged) + .onEnded(handleDragEnded) + ) + } + + private var swipeBackground: some View { + backgroundColor + .overlay(alignment: .trailing) { + actionView() + .onWidthChange { width in + actionViewWidth = width + actionViewOffset = width + } + .offset(x: actionViewOffset) + } + .animation(.smooth, value: actionViewOffset) + } + + // MARK: - Drag In Progress + + private func handleDragChanged(_ value: DragGesture.Value) { + let translation = value.translation.width + + // We are only interested in swipes to the left + guard translation < 0 else { return } + + hapticGenerator.prepare() + cellContentOffset = max(translation, -cellWidth) + + handleActionThresholdCrossing() + updateActionViewPosition() + } + + private func handleActionThresholdCrossing() { + let newIsActionThresholdReached = abs(cellContentOffset) >= actionThreshold + + if newIsActionThresholdReached && !isActionThresholdReached { + hapticGenerator.impactOccurred() + isActionThresholdReached = true + actionViewOffset = cellContentOffset + actionViewWidth + } else if !newIsActionThresholdReached && isActionThresholdReached { + hapticGenerator.impactOccurred() + isActionThresholdReached = false + actionViewOffset = 0 + } + } + + private func updateActionViewPosition() { + let revealedWidth = abs(cellContentOffset) + + if isActionThresholdReached { + actionViewOffset = cellContentOffset + actionViewWidth + } else { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + if revealedWidth < actionViewWidth { + actionViewOffset = actionViewWidth + cellContentOffset + } else { + actionViewOffset = 0 + } + } + } + } + + // MARK: - Drag Finish + + private func handleDragEnded(_: DragGesture.Value) { + if isActionThresholdReached { + animateToOpenedState() + } else { + animateToClosedState() + } + } + + private func animateToOpenedState() { + withAnimation(.smooth) { + cellContentOffset = -cellWidth + actionViewOffset = -cellWidth + actionViewWidth + } + } + + private func animateToClosedState() { + isActionThresholdReached = false + withAnimation(.smooth) { + cellContentOffset = 0 + actionViewOffset = actionViewWidth + } + } +} diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/View+MeasuringSize.swift b/Core/Core/Common/CommonUI/SwiftUIViews/View+MeasuringSize.swift index 4725aef908..b3a7f887c6 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/View+MeasuringSize.swift +++ b/Core/Core/Common/CommonUI/SwiftUIViews/View+MeasuringSize.swift @@ -82,4 +82,21 @@ extension View { binding.wrappedValue = height } } + + public func onWidthChange(_ perform: @escaping (CGFloat) -> Void) -> some View { + onGeometryChange(for: CGFloat.self) { geometry in + geometry.size.width + } action: { height in + perform(height) + } + } + + public func onWidthChange(update binding: Binding) -> some View { + onGeometryChange(for: CGFloat.self) { geometry in + geometry.size.width + } action: { height in + binding.wrappedValue = height + } + } + } diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index 5c9b11fb26..e8da6db585 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -38,13 +38,29 @@ struct TodoListItemCell: View { .paddingStyle(.leading, .cellAccessoryPadding) } .padding(.vertical, 8) - .background(.backgroundLightest) + } + .background(.backgroundLightest) .accessibilityElement(children: .combine) - .onSwipe(trailing: swipeActions) + .fullSwipeAction( + backgroundColor: .backgroundSuccess, + onSwipe: { onMarkAsDone(item) }, + actionView: { swipeActionView } + ) } } + private var swipeActionView: some View { + HStack(spacing: 12) { + Text("Done", bundle: .core) + .font(.semibold16, lineHeight: .fit) + Image.checkLine + .scaledIcon(size: 24) + } + .paddingStyle(.horizontal, .standard) + .foregroundStyle(Color.textLightest) + } + @ViewBuilder private var checkboxButton: some View { Button { @@ -66,20 +82,6 @@ struct TodoListItemCell: View { .identifier("to-do.list.\(item.id).checkbox") } - private var swipeActions: [SwipeModel] { - [ - SwipeModel( - id: "done", - image: { Image.completeLine }, - action: { onMarkAsDone(item) }, - style: SwipeStyle( - background: .backgroundSuccess, - foregroundColor: .textLightest, - slotWidth: 60 - ) - ) - ] - } } #if DEBUG diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 5a087c2285..4fe5bf1235 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -42,7 +42,6 @@ public struct TodoListScreen: View { ForEach(viewModel.items) { group in groupView(for: group) } - .paddingStyle(.trailing, .standard) InstUI.Divider() } } @@ -70,7 +69,9 @@ public struct TodoListScreen: View { let isLastItemInGroup = (group.items.last == item) if !isLastItemInGroup { - InstUI.Divider().padding(.leading, leadingPadding) + InstUI.Divider() + .padding(.leading, leadingPadding) + .paddingStyle(.trailing, .standard) } } } header: { @@ -78,7 +79,7 @@ public struct TodoListScreen: View { let isFirstSection = (viewModel.items.first == group) if !isFirstSection { - InstUI.Divider().paddingStyle(.leading, .standard) + InstUI.Divider().paddingStyle(.horizontal, .standard) } TodoDayHeaderView(group: group) { group in diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index f17233c3e9..9237f98cfe 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -73,26 +73,6 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO self.markDoneState = plannable.isMarkedComplete ? .done : .notDone } - /// Helper function to determine the context name for a Todo item. - /// - Parameters: - /// - isCourseNameNickname: Whether the course name is a user-given nickname. - /// - courseName: The course name (which may be a nickname if isCourseNameNickname is true). - /// - courseCode: The course code. - /// - fallback: Fallback value if no course data is available. - /// - Returns: The appropriate context name. - public static func contextName( - isCourseNameNickname: Bool, - courseName: String?, - courseCode: String?, - fallback: String = "" - ) -> String { - if isCourseNameNickname { - return courseName ?? fallback - } else { - return courseCode ?? courseName ?? fallback - } - } - public init( id: String, type: PlannableType, @@ -123,6 +103,26 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO self.overrideId = overrideId } + /// Helper function to determine the context name for a Todo item. + /// - Parameters: + /// - isCourseNameNickname: Whether the course name is a user-given nickname. + /// - courseName: The course name (which may be a nickname if isCourseNameNickname is true). + /// - courseCode: The course code. + /// - fallback: Fallback value if no course data is available. + /// - Returns: The appropriate context name. + public static func contextName( + isCourseNameNickname: Bool, + courseName: String?, + courseCode: String?, + fallback: String = "" + ) -> String { + if isCourseNameNickname { + return courseName ?? fallback + } else { + return courseCode ?? courseName ?? fallback + } + } + // MARK: - Equatable public static func == (lhs: TodoItemViewModel, rhs: TodoItemViewModel) -> Bool { @@ -142,10 +142,13 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO public static func < (lhs: TodoItemViewModel, rhs: TodoItemViewModel) -> Bool { lhs.date < rhs.date } +} + +#if DEBUG - // MARK: Preview & Testing +// MARK: Preview & Testing - #if DEBUG +extension TodoItemViewModel { public static func make( id: String = "1", @@ -222,6 +225,6 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO icon: icon ) } - - #endif } + +#endif diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift index 7d6b826492..0ff1b54e30 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift @@ -37,6 +37,7 @@ class TodoInteractorLiveTests: CoreTestCase { override func tearDown() { testee = nil + Clock.reset() super.tearDown() } From d35a7b7d3236210af7982d685aca2b0000be0711 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 21 Oct 2025 14:02:00 +0200 Subject: [PATCH 38/49] Add swipe to done feature. --- ...lSwipeAction.swift => SwipeToRemove.swift} | 19 +- .../Features/Todos/Model/TodoInteractor.swift | 17 +- .../Todos/View/TodoListItemCell.swift | 9 +- .../Features/Todos/View/TodoListScreen.swift | 3 +- .../Todos/ViewModel/TodoListViewModel.swift | 60 +++++ .../ViewModel/TodoListViewModelTests.swift | 211 ++++++++++++++++++ 6 files changed, 300 insertions(+), 19 deletions(-) rename Core/Core/Common/CommonUI/SwiftUIViews/{FullSwipeAction.swift => SwipeToRemove.swift} (91%) diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/FullSwipeAction.swift b/Core/Core/Common/CommonUI/SwiftUIViews/SwipeToRemove.swift similarity index 91% rename from Core/Core/Common/CommonUI/SwiftUIViews/FullSwipeAction.swift rename to Core/Core/Common/CommonUI/SwiftUIViews/SwipeToRemove.swift index 1632737f52..1e78d73dab 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/FullSwipeAction.swift +++ b/Core/Core/Common/CommonUI/SwiftUIViews/SwipeToRemove.swift @@ -19,12 +19,12 @@ import SwiftUI public extension View { - func fullSwipeAction( + func swipeToRemove( backgroundColor: Color, onSwipe: @escaping () -> Void, @ViewBuilder actionView: @escaping () -> ActionView ) -> some View { - modifier(FullSwipeActionModifier( + modifier(SwipeToRemoveModifier( backgroundColor: backgroundColor, onSwipe: onSwipe, actionView: actionView @@ -32,19 +32,24 @@ public extension View { } } -private struct FullSwipeActionModifier: ViewModifier { +private struct SwipeToRemoveModifier: ViewModifier { let backgroundColor: Color let onSwipe: () -> Void let actionView: () -> ActionView + // MARK: - Layout & Sizing @State private var cellContentOffset: CGFloat = 0 @State private var cellWidth: CGFloat = 0 @State private var actionViewWidth: CGFloat = 0 /// The point based horizontal offset that must be reached by the drag gesture to trigger the action. @State private var actionThreshold: CGFloat = 0 + @State private var actionViewOffset: CGFloat = 0 + + // MARK: - Internal Logic States /// Becomes true, if dragging goes beyond `actionThreshold`. If grad is ended while this is true the swipe action will be performed. @State private var isActionThresholdReached = false - @State private var actionViewOffset: CGFloat = 0 + /// Becomes true after the action has been invoked to disable further drag gestures + @State private var isActionInvoked = false private let hapticGenerator = UIImpactFeedbackGenerator(style: .medium) @@ -82,6 +87,8 @@ private struct FullSwipeActionModifier: ViewModifier { // MARK: - Drag In Progress private func handleDragChanged(_ value: DragGesture.Value) { + if isActionInvoked { return } + let translation = value.translation.width // We are only interested in swipes to the left @@ -129,8 +136,12 @@ private struct FullSwipeActionModifier: ViewModifier { // MARK: - Drag Finish private func handleDragEnded(_: DragGesture.Value) { + if isActionInvoked { return } + if isActionThresholdReached { animateToOpenedState() + isActionInvoked = true + onSwipe() } else { animateToClosedState() } diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index fd62222b2a..e95deb7bc0 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -20,17 +20,14 @@ import Foundation import Combine public protocol TodoInteractor { - var todoGroups: AnyPublisher<[TodoGroupViewModel], Never> { get } + var todoGroups: CurrentValueSubject<[TodoGroupViewModel], Never> { get } func refresh(ignoreCache: Bool) -> AnyPublisher func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher } public final class TodoInteractorLive: TodoInteractor { - public var todoGroups: AnyPublisher<[TodoGroupViewModel], Never> { - todoGroupsSubject.eraseToAnyPublisher() - } + public var todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>([]) - private let todoGroupsSubject = CurrentValueSubject<[TodoGroupViewModel], Never>([]) private let env: AppEnvironment private var subscriptions = Set() @@ -69,12 +66,12 @@ public final class TodoInteractorLive: TodoInteractor { } } } - .map { [weak todoGroupsSubject] (todos: [TodoItemViewModel]) in + .map { [weak todoGroups] (todos: [TodoItemViewModel]) in TabBarBadgeCounts.todoListCount = UInt(todos.count) // Group todos by day let groupedTodos = Self.groupTodosByDay(todos) - todoGroupsSubject?.value = groupedTodos + todoGroups?.value = groupedTodos return () } .eraseToAnyPublisher() @@ -121,11 +118,11 @@ public final class TodoInteractorLive: TodoInteractor { #if DEBUG public final class TodoInteractorPreview: TodoInteractor { - public let todoGroups: AnyPublisher<[TodoGroupViewModel], Never> + public let todoGroups: CurrentValueSubject<[TodoGroupViewModel], Never> public init(todoGroups: [TodoGroupViewModel]? = nil) { if let todoGroups { - self.todoGroups = Publishers.typedJust(todoGroups) + self.todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>(todoGroups) return } @@ -145,7 +142,7 @@ public final class TodoInteractorPreview: TodoInteractor { .makeLongText(id: "2") ] ) - self.todoGroups = Publishers.typedJust([todayGroup, tomorrowGroup]) + self.todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>([todayGroup, tomorrowGroup]) } public func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index e8da6db585..f3a276083c 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -25,6 +25,7 @@ struct TodoListItemCell: View { @ObservedObject var item: TodoItemViewModel let onTap: (_ item: TodoItemViewModel, _ viewController: WeakViewController) -> Void let onMarkAsDone: (_ item: TodoItemViewModel) -> Void + let onSwipeMarkAsDone: (_ item: TodoItemViewModel) -> Void var body: some View { VStack(spacing: 0) { @@ -42,9 +43,9 @@ struct TodoListItemCell: View { } .background(.backgroundLightest) .accessibilityElement(children: .combine) - .fullSwipeAction( + .swipeToRemove( backgroundColor: .backgroundSuccess, - onSwipe: { onMarkAsDone(item) }, + onSwipe: { onSwipeMarkAsDone(item) }, actionView: { swipeActionView } ) } @@ -88,8 +89,8 @@ struct TodoListItemCell: View { #Preview { VStack(spacing: 0) { - TodoListItemCell(item: .makeShortText(), onTap: { _, _ in }, onMarkAsDone: { _ in }) - TodoListItemCell(item: .makeLongText(), onTap: { _, _ in }, onMarkAsDone: { _ in }) + TodoListItemCell(item: .makeShortText(), onTap: { _, _ in }, onMarkAsDone: { _ in }, onSwipeMarkAsDone: { _ in }) + TodoListItemCell(item: .makeLongText(), onTap: { _, _ in }, onMarkAsDone: { _ in }, onSwipeMarkAsDone: { _ in }) } .background(Color.backgroundLightest) } diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 4fe5bf1235..80c1f29b74 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -58,7 +58,8 @@ public struct TodoListScreen: View { TodoListItemCell( item: item, onTap: viewModel.didTapItem, - onMarkAsDone: viewModel.markItemAsDone + onMarkAsDone: viewModel.markItemAsDone, + onSwipeMarkAsDone: viewModel.markItemAsDoneWithOptimisticUI ) .padding(.leading, leadingPadding) .transition(.asymmetric( diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index 0a23b0a774..c1e774d831 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -40,6 +40,8 @@ public class TodoListViewModel: ObservableObject { private var subscriptions = Set() /// Tracks cancellable timers for items in the done state waiting to be removed after 3 seconds private var markDoneTimers: [String: AnyCancellable] = [:] + /// Tracks item IDs that have been optimistically removed via swipe and are awaiting API response + private var optimisticallyRemovedIds: Set = [] init( interactor: TodoInteractor, @@ -113,6 +115,64 @@ public class TodoListViewModel: ObservableObject { } } + func markItemAsDoneWithOptimisticUI(_ item: TodoItemViewModel) { + optimisticallyRemovedIds.insert(item.id) + + withAnimation { + removeItem(item) + } + + let itemId = item.id + + interactor.markItemAsDone(item, done: true) + .receive(on: scheduler) + .sinkFailureOrValue { [weak self] _ in + guard let self else { return } + self.restoreItem(withId: itemId) + self.optimisticallyRemovedIds.remove(itemId) + self.snackBar.showSnack(String(localized: "Failed to mark item as done", bundle: .core)) + } receiveValue: { [weak self] _ in + guard let self else { return } + self.optimisticallyRemovedIds.remove(itemId) + + if TabBarBadgeCounts.todoListCount > 0 { + TabBarBadgeCounts.todoListCount -= 1 + } + } + .store(in: &subscriptions) + } + + private func restoreItem(withId itemId: String) { + guard let itemToRestore = interactor.todoGroups.value + .flatMap({ $0.items }) + .first(where: { $0.id == itemId }) else { + return + } + + withAnimation { + var updatedItems = items + let groupDate = itemToRestore.date.startOfDay() + + if let groupIndex = updatedItems.firstIndex(where: { $0.date == groupDate }) { + let group = updatedItems[groupIndex] + var groupItems = group.items + groupItems.append(itemToRestore) + groupItems.sort() + updatedItems[groupIndex] = TodoGroupViewModel(date: group.date, items: groupItems) + } else { + let newGroup = TodoGroupViewModel(date: groupDate, items: [itemToRestore]) + updatedItems.append(newGroup) + updatedItems.sort() + } + + items = updatedItems + + if state == .empty { + state = .data + } + } + } + private func performMarkAsDone(_ item: TodoItemViewModel) { markDoneTimers[item.id]?.cancel() item.markDoneState = .loading diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift index 41d6a83863..8bf827c386 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift @@ -505,4 +505,215 @@ class TodoListViewModelTests: CoreTestCase { XCTAssertEqual(TabBarBadgeCounts.todoListCount, 0) XCTAssertEqual(item.markDoneState, .done) } + + // MARK: - Optimistic UI Tests + + func test_markItemAsDoneWithOptimisticUI_removesItemImmediately() { + // GIVEN + interactor.markItemAsDoneResult = .success(()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let group = TodoGroupViewModel(date: Date(), items: [item]) + testee.items = [group] + + // WHEN + testee.markItemAsDoneWithOptimisticUI(item) + + // THEN + XCTAssertEqual(testee.items.count, 0) + XCTAssertTrue(interactor.markItemAsDoneCalled) + } + + func test_markItemAsDoneWithOptimisticUI_onSuccess_staysRemoved() { + // GIVEN + TabBarBadgeCounts.todoListCount = 5 + interactor.markItemAsDoneResult = .success(()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let group = TodoGroupViewModel(date: Date(), items: [item]) + testee.items = [group] + + // WHEN + testee.markItemAsDoneWithOptimisticUI(item) + testScheduler.advance() + + // THEN + XCTAssertEqual(testee.items.count, 0) + XCTAssertEqual(TabBarBadgeCounts.todoListCount, 4) + } + + func test_markItemAsDoneWithOptimisticUI_onFailure_restoresItem() { + // GIVEN + TabBarBadgeCounts.todoListCount = 5 + interactor.markItemAsDoneResult = .failure(NSError.internalError()) + let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", date: Date()) + let group = TodoGroupViewModel(date: Date().startOfDay(), items: [item]) + testee.items = [group] + interactor.todoGroupsSubject.send([group]) + + // WHEN + testee.markItemAsDoneWithOptimisticUI(item) + XCTAssertEqual(testee.items.count, 0) + + testScheduler.advance() + + // THEN + XCTAssertEqual(testee.items.count, 1) + XCTAssertEqual(testee.items.first?.items.count, 1) + XCTAssertEqual(testee.items.first?.items.first?.id, "1") + XCTAssertEqual(TabBarBadgeCounts.todoListCount, 5) + XCTAssertNotNil(testee.snackBar.visibleSnack) + } + + func test_markItemAsDoneWithOptimisticUI_multipleConcurrentSwipes_allSucceed() { + // GIVEN + TabBarBadgeCounts.todoListCount = 3 + interactor.markItemAsDoneResult = .success(()) + let item1 = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let item2 = TodoItemViewModel.make(id: "2", plannableType: "assignment") + let item3 = TodoItemViewModel.make(id: "3", plannableType: "assignment") + let group = TodoGroupViewModel(date: Date(), items: [item1, item2, item3]) + testee.items = [group] + + // WHEN + testee.markItemAsDoneWithOptimisticUI(item1) + testee.markItemAsDoneWithOptimisticUI(item2) + testee.markItemAsDoneWithOptimisticUI(item3) + testScheduler.advance() + + // THEN + XCTAssertEqual(testee.items.count, 0) + XCTAssertEqual(TabBarBadgeCounts.todoListCount, 0) + XCTAssertEqual(interactor.markItemAsDoneCallCount, 3) + } + + func test_markItemAsDoneWithOptimisticUI_multipleConcurrentSwipes_allFail() { + // GIVEN + TabBarBadgeCounts.todoListCount = 3 + interactor.markItemAsDoneResult = .failure(NSError.internalError()) + let item1 = TodoItemViewModel.make(id: "1", plannableType: "assignment", date: Date()) + let item2 = TodoItemViewModel.make(id: "2", plannableType: "assignment", date: Date()) + let item3 = TodoItemViewModel.make(id: "3", plannableType: "assignment", date: Date()) + let group = TodoGroupViewModel(date: Date().startOfDay(), items: [item1, item2, item3]) + testee.items = [group] + interactor.todoGroupsSubject.send([group]) + + // WHEN + testee.markItemAsDoneWithOptimisticUI(item1) + testee.markItemAsDoneWithOptimisticUI(item2) + testee.markItemAsDoneWithOptimisticUI(item3) + XCTAssertEqual(testee.items.count, 0) + + testScheduler.advance() + + // THEN + XCTAssertEqual(testee.items.count, 1) + XCTAssertEqual(testee.items.first?.items.count, 3) + XCTAssertEqual(TabBarBadgeCounts.todoListCount, 3) + XCTAssertNotNil(testee.snackBar.visibleSnack) + } + + func test_markItemAsDoneWithOptimisticUI_multipleConcurrentSwipes_mixedResults() { + // GIVEN + TabBarBadgeCounts.todoListCount = 3 + let item1 = TodoItemViewModel.make(id: "1", plannableType: "assignment", date: Date()) + let item2 = TodoItemViewModel.make(id: "2", plannableType: "assignment", date: Date()) + let item3 = TodoItemViewModel.make(id: "3", plannableType: "assignment", date: Date()) + let group = TodoGroupViewModel(date: Date().startOfDay(), items: [item1, item2, item3]) + testee.items = [group] + interactor.todoGroupsSubject.send([group]) + + var callCount = 0 + interactor.markItemAsDoneResult = .success(()) + + // WHEN - swipe all items + testee.markItemAsDoneWithOptimisticUI(item1) + + // Change result to failure for item2 + interactor.markItemAsDoneResult = .failure(NSError.internalError()) + testee.markItemAsDoneWithOptimisticUI(item2) + + // Change result back to success for item3 + interactor.markItemAsDoneResult = .success(()) + testee.markItemAsDoneWithOptimisticUI(item3) + + XCTAssertEqual(testee.items.count, 0) + + testScheduler.advance() + + // THEN - only item2 should be restored + XCTAssertEqual(testee.items.count, 1) + XCTAssertEqual(testee.items.first?.items.count, 1) + XCTAssertEqual(testee.items.first?.items.first?.id, "2") + XCTAssertEqual(TabBarBadgeCounts.todoListCount, 1) + } + + func test_markItemAsDoneWithOptimisticUI_restoresToCorrectGroup() { + // GIVEN + interactor.markItemAsDoneResult = .failure(NSError.internalError()) + let today = Date().startOfDay() + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)! + + let item1 = TodoItemViewModel.make(id: "1", date: today) + let item2 = TodoItemViewModel.make(id: "2", date: tomorrow) + let group1 = TodoGroupViewModel(date: today, items: [item1]) + let group2 = TodoGroupViewModel(date: tomorrow, items: [item2]) + + testee.items = [group1, group2] + interactor.todoGroupsSubject.send([group1, group2]) + + // WHEN + testee.markItemAsDoneWithOptimisticUI(item1) + testScheduler.advance() + + // THEN + XCTAssertEqual(testee.items.count, 2) + XCTAssertEqual(testee.items.first?.date, today) + XCTAssertEqual(testee.items.first?.items.first?.id, "1") + XCTAssertEqual(testee.items.last?.date, tomorrow) + XCTAssertEqual(testee.items.last?.items.first?.id, "2") + } + + func test_markItemAsDoneWithOptimisticUI_recreatesGroupIfNeeded() { + // GIVEN + interactor.markItemAsDoneResult = .failure(NSError.internalError()) + let today = Date().startOfDay() + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)! + + let item1 = TodoItemViewModel.make(id: "1", date: today) + let item2 = TodoItemViewModel.make(id: "2", date: tomorrow) + let group1 = TodoGroupViewModel(date: today, items: [item1]) + let group2 = TodoGroupViewModel(date: tomorrow, items: [item2]) + + testee.items = [group1, group2] + interactor.todoGroupsSubject.send([group1, group2]) + + // WHEN - remove all items from first group + testee.markItemAsDoneWithOptimisticUI(item1) + XCTAssertEqual(testee.items.count, 1) + + testScheduler.advance() + + // THEN - first group should be recreated + XCTAssertEqual(testee.items.count, 2) + XCTAssertEqual(testee.items.first?.date, today) + } + + func test_markItemAsDoneWithOptimisticUI_stateTransition_emptyToData() { + // GIVEN + interactor.markItemAsDoneResult = .failure(NSError.internalError()) + let item = TodoItemViewModel.make(id: "1", date: Date()) + let group = TodoGroupViewModel(date: Date().startOfDay(), items: [item]) + testee.items = [group] + testee.state = .data + interactor.todoGroupsSubject.send([group]) + + // WHEN + testee.markItemAsDoneWithOptimisticUI(item) + XCTAssertEqual(testee.state, .empty) + + testScheduler.advance() + + // THEN + XCTAssertEqual(testee.state, .data) + XCTAssertEqual(testee.items.count, 1) + } } From 4080d54c195714d46e13d867a89d635bdabb3483 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 21 Oct 2025 14:19:51 +0200 Subject: [PATCH 39/49] Rename id to plannableId. --- .../Features/Todos/Model/TodoInteractor.swift | 10 +- .../Todos/View/TodoDayHeaderView.swift | 4 +- .../Todos/View/TodoListItemCell.swift | 2 +- .../Todos/ViewModel/TodoItemViewModel.swift | 24 ++-- .../Todos/ViewModel/TodoListViewModel.swift | 22 ++-- .../Todos/Model/TodoInteractorMock.swift | 6 +- .../ViewModel/TodoGroupViewModelTests.swift | 6 +- .../ViewModel/TodoItemViewModelTests.swift | 18 +-- .../ViewModel/TodoListViewModelTests.swift | 121 +++++++++--------- Student/Widgets/Common/Model/Routes.swift | 4 +- .../Widgets/TodoWidget/Model/TodoModel.swift | 14 +- 11 files changed, 113 insertions(+), 118 deletions(-) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index e95deb7bc0..55c709fe75 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -79,7 +79,7 @@ public final class TodoInteractorLive: TodoInteractor { public func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher { let useCase = MarkPlannableItemDone( - plannableId: item.id, + plannableId: item.plannableId, plannableType: item.plannableType, overrideId: item.overrideId, done: done @@ -94,7 +94,7 @@ public final class TodoInteractorLive: TodoInteractor { } private func updateOverrideId(for item: TodoItemViewModel) { - let scope = Scope.plannable(id: item.id) + let scope = Scope.plannable(id: item.plannableId) if let plannable: Plannable = env.database.viewContext.fetch(scope: scope).first, let overrideId = plannable.plannerOverrideId { item.overrideId = overrideId @@ -132,14 +132,14 @@ public final class TodoInteractorPreview: TodoInteractor { let todayGroup = TodoGroupViewModel( date: today, items: [ - .makeShortText(id: "3") + .makeShortText(plannableId: "3") ] ) let tomorrowGroup = TodoGroupViewModel( date: tomorrow, items: [ - .makeShortText(id: "1"), - .makeLongText(id: "2") + .makeShortText(plannableId: "1"), + .makeLongText(plannableId: "2") ] ) self.todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>([todayGroup, tomorrowGroup]) diff --git a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift index d46ada6c9e..eb8856073b 100644 --- a/Core/Core/Features/Todos/View/TodoDayHeaderView.swift +++ b/Core/Core/Features/Todos/View/TodoDayHeaderView.swift @@ -84,11 +84,11 @@ private extension CGFloat { let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() let todayGroup = TodoGroupViewModel( date: today, - items: [.makeShortText(id: "1")] + items: [.makeShortText(plannableId: "1")] ) let tomorrowGroup = TodoGroupViewModel( date: tomorrow, - items: [.makeShortText(id: "1")] + items: [.makeShortText(plannableId: "1")] ) HStack(spacing: 0) { diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index f3a276083c..a7c79112cb 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -80,7 +80,7 @@ struct TodoListItemCell: View { .buttonStyle(.plain) .frame(width: 44, height: 44) .tint(Color(Brand.shared.primary)) - .identifier("to-do.list.\(item.id).checkbox") + .identifier("to-do.list.\(item.plannableId).checkbox") } } diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index 9237f98cfe..7d6491dfbd 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -25,8 +25,7 @@ public enum MarkDoneState: Equatable { case done } -public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableObject { - public let id: String +public class TodoItemViewModel: /*Identifiable,*/ Equatable, Comparable, ObservableObject { public let type: PlannableType public let date: Date public let dateText: String @@ -39,6 +38,7 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO public let color: Color public let icon: Image + public let plannableId: String public let plannableType: String public var overrideId: String? @@ -47,7 +47,7 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO public init?(_ plannable: Plannable, course: Course? = nil) { guard let date = plannable.date else { return nil } - self.id = plannable.id + self.plannableId = plannable.id self.type = plannable.plannableType self.date = date self.dateText = date.timeOnlyString @@ -74,7 +74,7 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO } public init( - id: String, + plannableId: String, type: PlannableType, date: Date, title: String, @@ -86,7 +86,7 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO plannableType: String = "assignment", overrideId: String? = nil ) { - self.id = id + self.plannableId = plannableId self.type = type self.date = date self.dateText = date.timeOnlyString @@ -126,7 +126,7 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO // MARK: - Equatable public static func == (lhs: TodoItemViewModel, rhs: TodoItemViewModel) -> Bool { - lhs.id == rhs.id && + lhs.plannableId == rhs.plannableId && lhs.type == rhs.type && lhs.date == rhs.date && lhs.title == rhs.title && @@ -151,7 +151,7 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO extension TodoItemViewModel { public static func make( - id: String = "1", + plannableId: String = "1", type: PlannableType = .assignment, date: Date = Clock.now, title: String = "Calculate how far the Millennium Falcon actually traveled in less than 12 parsecs", @@ -164,7 +164,7 @@ extension TodoItemViewModel { overrideId: String? = nil ) -> TodoItemViewModel { TodoItemViewModel( - id: id, + plannableId: plannableId, type: type, date: date, title: title, @@ -179,7 +179,7 @@ extension TodoItemViewModel { } public static func makeShortText( - id: String = "1", + plannableId: String = "1", type: PlannableType = .assignment, date: Date = Clock.now, title: String = "Quiz 1", @@ -190,7 +190,7 @@ extension TodoItemViewModel { icon: Image = .quizLine ) -> TodoItemViewModel { TodoItemViewModel( - id: id, + plannableId: plannableId, type: type, date: date, title: title, @@ -203,7 +203,7 @@ extension TodoItemViewModel { } public static func makeLongText( - id: String = "1", + plannableId: String = "1", type: PlannableType = .assignment, date: Date = Clock.now, title: String = "Complete comprehensive reading assignment covering advanced mathematical concepts and theoretical applications", @@ -214,7 +214,7 @@ extension TodoItemViewModel { icon: Image = .assignmentLine ) -> TodoItemViewModel { TodoItemViewModel( - id: id, + plannableId: plannableId, type: type, date: date, title: title, diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index c1e774d831..a6760faae2 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -75,10 +75,10 @@ public class TodoListViewModel: ObservableObject { func didTapItem(_ item: TodoItemViewModel, _ viewController: WeakViewController) { switch item.type { case .planner_note: - let vc = PlannerAssembly.makeToDoDetailsViewController(plannableId: item.id) + let vc = PlannerAssembly.makeToDoDetailsViewController(plannableId: item.plannableId) router.show(vc, from: viewController, options: .detail) case .calendar_event: - let vc = PlannerAssembly.makeEventDetailsViewController(eventId: item.id) + let vc = PlannerAssembly.makeEventDetailsViewController(eventId: item.plannableId) router.show(vc, from: viewController, options: .detail) default: guard let url = item.htmlURL else { return } @@ -116,13 +116,13 @@ public class TodoListViewModel: ObservableObject { } func markItemAsDoneWithOptimisticUI(_ item: TodoItemViewModel) { - optimisticallyRemovedIds.insert(item.id) + optimisticallyRemovedIds.insert(item.plannableId) withAnimation { removeItem(item) } - let itemId = item.id + let itemId = item.plannableId interactor.markItemAsDone(item, done: true) .receive(on: scheduler) @@ -145,7 +145,7 @@ public class TodoListViewModel: ObservableObject { private func restoreItem(withId itemId: String) { guard let itemToRestore = interactor.todoGroups.value .flatMap({ $0.items }) - .first(where: { $0.id == itemId }) else { + .first(where: { $0.plannableId == itemId }) else { return } @@ -174,7 +174,7 @@ public class TodoListViewModel: ObservableObject { } private func performMarkAsDone(_ item: TodoItemViewModel) { - markDoneTimers[item.id]?.cancel() + markDoneTimers[item.plannableId]?.cancel() item.markDoneState = .loading interactor.markItemAsDone(item, done: true) @@ -190,8 +190,8 @@ public class TodoListViewModel: ObservableObject { } private func performMarkAsUndone(_ item: TodoItemViewModel) { - markDoneTimers[item.id]?.cancel() - markDoneTimers.removeValue(forKey: item.id) + markDoneTimers[item.plannableId]?.cancel() + markDoneTimers.removeValue(forKey: item.plannableId) item.markDoneState = .loading interactor.markItemAsDone(item, done: false) @@ -220,10 +220,10 @@ public class TodoListViewModel: ObservableObject { withAnimation { self?.removeItem(item) } - self?.markDoneTimers.removeValue(forKey: item.id) + self?.markDoneTimers.removeValue(forKey: item.plannableId) } - markDoneTimers[item.id] = timer + markDoneTimers[item.plannableId] = timer } private func handleMarkAsDoneError(_ item: TodoItemViewModel, _ error: Error) { @@ -238,7 +238,7 @@ public class TodoListViewModel: ObservableObject { private func removeItem(_ item: TodoItemViewModel) { items = items.compactMap { group in - let filteredItems = group.items.filter { $0.id != item.id } + let filteredItems = group.items.filter { $0.plannableId != item.plannableId } guard !filteredItems.isEmpty else { return nil } return TodoGroupViewModel(date: group.date, items: filteredItems) } diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift index ac573eec6a..184b753d01 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift @@ -20,11 +20,7 @@ import Combine final class TodoInteractorMock: TodoInteractor { - var todoGroups: AnyPublisher<[TodoGroupViewModel], Never> { - todoGroupsSubject.eraseToAnyPublisher() - } - - let todoGroupsSubject = CurrentValueSubject<[TodoGroupViewModel], Never>([]) + var todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>([]) var refreshCalled = false var refreshCallCount = 0 var lastIgnoreCache = false diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift index d710c80bb0..0370dea960 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoGroupViewModelTests.swift @@ -24,7 +24,7 @@ class TodoGroupViewModelTests: CoreTestCase { func testAccessibilityLabel() { let date = Date.make(year: 2021, month: 8, day: 7, hour: 12) - let items = [TodoItemViewModel.make(id: "1"), TodoItemViewModel.make(id: "2")] + let items = [TodoItemViewModel.make(plannableId: "1"), TodoItemViewModel.make(plannableId: "2")] let group = TodoGroupViewModel(date: date, items: items) @@ -35,7 +35,7 @@ class TodoGroupViewModelTests: CoreTestCase { func testDateFormatting() { let date = Date.make(year: 2021, month: 12, day: 25, hour: 15) - let items = [TodoItemViewModel.make(id: "1")] + let items = [TodoItemViewModel.make(plannableId: "1")] let group = TodoGroupViewModel(date: date, items: items) @@ -60,7 +60,7 @@ class TodoGroupViewModelTests: CoreTestCase { func testComparison() { let date1 = Date.make(year: 2021, month: 1, day: 1) let date2 = Date.make(year: 2021, month: 1, day: 2) - let items = [TodoItemViewModel.make(id: "1")] + let items = [TodoItemViewModel.make(plannableId: "1")] let group1 = TodoGroupViewModel(date: date1, items: items) let group2 = TodoGroupViewModel(date: date2, items: items) diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift index f74020e244..ffbdf662be 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoItemViewModelTests.swift @@ -40,7 +40,7 @@ class TodoItemViewModelTests: CoreTestCase { // Then XCTAssertNotNil(todoItem) - XCTAssertEqual(todoItem?.id, "test-id") + XCTAssertEqual(todoItem?.plannableId, "test-id") XCTAssertEqual(todoItem?.type, .assignment) XCTAssertEqual(todoItem?.date, date) XCTAssertEqual(todoItem?.dateText, date.timeOnlyString) @@ -130,7 +130,7 @@ class TodoItemViewModelTests: CoreTestCase { let date = Date() let url = URL(string: "https://example.com")! let todoItem = TodoItemViewModel( - id: "direct-id", + plannableId: "direct-id", type: .quiz, date: date, title: "Direct Quiz", @@ -142,7 +142,7 @@ class TodoItemViewModelTests: CoreTestCase { ) // Then - XCTAssertEqual(todoItem.id, "direct-id") + XCTAssertEqual(todoItem.plannableId, "direct-id") XCTAssertEqual(todoItem.type, .quiz) XCTAssertEqual(todoItem.date, date) XCTAssertEqual(todoItem.dateText, date.timeOnlyString) @@ -158,7 +158,7 @@ class TodoItemViewModelTests: CoreTestCase { let date = Date() let url = URL(string: "https://example.com")! let todoItem = TodoItemViewModel.make( - id: "factory-id", + plannableId: "factory-id", type: .discussion_topic, date: date, title: "Factory Discussion", @@ -170,7 +170,7 @@ class TodoItemViewModelTests: CoreTestCase { ) // Then - XCTAssertEqual(todoItem.id, "factory-id") + XCTAssertEqual(todoItem.plannableId, "factory-id") XCTAssertEqual(todoItem.type, .discussion_topic) XCTAssertEqual(todoItem.date, date) XCTAssertEqual(todoItem.title, "Factory Discussion") @@ -185,7 +185,7 @@ class TodoItemViewModelTests: CoreTestCase { let date = Date() let url = URL(string: "https://example.com")! let todoItem1 = TodoItemViewModel( - id: "same-id", + plannableId: "same-id", type: .assignment, date: date, title: "Same Title", @@ -197,7 +197,7 @@ class TodoItemViewModelTests: CoreTestCase { ) let todoItem2 = TodoItemViewModel( - id: "same-id", + plannableId: "same-id", type: .assignment, date: date, title: "Same Title", @@ -209,7 +209,7 @@ class TodoItemViewModelTests: CoreTestCase { ) let todoItem3 = TodoItemViewModel( - id: "different-id", + plannableId: "different-id", type: .assignment, date: date, title: "Same Title", @@ -256,7 +256,7 @@ class TodoItemViewModelTests: CoreTestCase { // When let todoItem = TodoItemViewModel( - id: "datetest-id", + plannableId: "datetest-id", type: .assignment, date: specificDate, title: "Date Test Assignment", diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift index 8bf827c386..69d11c66e3 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift @@ -57,13 +57,13 @@ class TodoListViewModelTests: CoreTestCase { func testItemsUpdateFromInteractor() { // Given let testItems = [ - TodoItemViewModel.make(id: "1", title: "Test Item 1"), - TodoItemViewModel.make(id: "2", title: "Test Item 2") + TodoItemViewModel.make(plannableId: "1", title: "Test Item 1"), + TodoItemViewModel.make(plannableId: "2", title: "Test Item 2") ] let testGroups = [TodoGroupViewModel(date: Date(), items: testItems)] // When - interactor.todoGroupsSubject.send(testGroups) + interactor.todoGroups.send(testGroups) // Then XCTAssertFirstValue(testee.$items) { items in @@ -110,7 +110,7 @@ class TodoListViewModelTests: CoreTestCase { // Given let expectation = expectation(description: "Refresh completion called") interactor.refreshResult = .success - interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [TodoItemViewModel.make(id: "1", title: "Test Item")])]) + interactor.todoGroups.send([TodoGroupViewModel(date: Date(), items: [TodoItemViewModel.make(plannableId: "1", title: "Test Item")])]) // When testee.refresh(completion: { @@ -126,7 +126,7 @@ class TodoListViewModelTests: CoreTestCase { // Given let expectation = expectation(description: "Refresh completion called") interactor.refreshResult = .success - interactor.todoGroupsSubject.send([]) + interactor.todoGroups.send([]) // When testee.refresh(completion: { @@ -155,8 +155,8 @@ class TodoListViewModelTests: CoreTestCase { func testDidTapItemPlannerNote() { // Given - let todo = TodoItemViewModel.make(id: "123", type: .planner_note) - interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [todo])]) + let todo = TodoItemViewModel.make(plannableId: "123", type: .planner_note) + interactor.todoGroups.send([TodoGroupViewModel(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -169,11 +169,11 @@ class TodoListViewModelTests: CoreTestCase { func testDidTapItemCalendarEvent() { // Given let todo = TodoItemViewModel.make( - id: "456", + plannableId: "456", type: .calendar_event, htmlURL: URL(string: "https://canvas.instructure.com/calendar") ) - interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [todo])]) + interactor.todoGroups.send([TodoGroupViewModel(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -186,10 +186,10 @@ class TodoListViewModelTests: CoreTestCase { func testDidTapItemOtherTypeWithURL() { // Given let todo = TodoItemViewModel.make( - id: "789", + plannableId: "789", type: .assignment, htmlURL: URL(string: "https://canvas.instructure.com/courses/1/assignments/789")) - interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [todo])]) + interactor.todoGroups.send([TodoGroupViewModel(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -200,8 +200,8 @@ class TodoListViewModelTests: CoreTestCase { func testDidTapItemOtherTypeWithoutURL() { // Given - let todo = TodoItemViewModel.make(id: "999", type: .assignment, htmlURL: nil as URL?) - interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [todo])]) + let todo = TodoItemViewModel.make(plannableId: "999", type: .assignment, htmlURL: nil as URL?) + interactor.todoGroups.send([TodoGroupViewModel(date: Date(), items: [todo])]) // When testee.didTapItem(todo, WeakViewController()) @@ -215,7 +215,7 @@ class TodoListViewModelTests: CoreTestCase { // When - with non-empty todos interactor.refreshResult = .success - interactor.todoGroupsSubject.send([TodoGroupViewModel(date: Date(), items: [TodoItemViewModel.make(id: "1", title: "Test")])]) + interactor.todoGroups.send([TodoGroupViewModel(date: Date(), items: [TodoItemViewModel.make(plannableId: "1", title: "Test")])]) testee.refresh(completion: {}, ignoreCache: false) // Then @@ -223,7 +223,7 @@ class TodoListViewModelTests: CoreTestCase { // When - with empty todos interactor.refreshResult = .success - interactor.todoGroupsSubject.send([]) + interactor.todoGroups.send([]) testee.refresh(completion: {}, ignoreCache: false) // Then @@ -265,7 +265,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDone_startsInNotDoneState() { // GIVEN - let item = TodoItemViewModel.make(id: "1") + let item = TodoItemViewModel.make(plannableId: "1") // THEN XCTAssertEqual(item.markDoneState, .notDone) @@ -274,7 +274,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDone_onSuccess_changesStateToDone() { // GIVEN interactor.markItemAsDoneResult = .success(()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment") // WHEN testee.markItemAsDone(item) @@ -283,14 +283,14 @@ class TodoListViewModelTests: CoreTestCase { // THEN XCTAssertEqual(item.markDoneState, .done) XCTAssertTrue(interactor.markItemAsDoneCalled) - XCTAssertEqual(interactor.lastMarkAsDoneItem?.id, "1") + XCTAssertEqual(interactor.lastMarkAsDoneItem?.plannableId, "1") XCTAssertEqual(interactor.lastMarkAsDoneDone, true) } func test_markItemAsDone_onError_changesStateBackToNotDone() { // GIVEN interactor.markItemAsDoneResult = .failure(NSError.internalError()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment", overrideId: "override-1") // WHEN testee.markItemAsDone(item) @@ -303,7 +303,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDone_onError_showsSnackBar() { // GIVEN interactor.markItemAsDoneResult = .failure(NSError.internalError()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment", overrideId: "override-1") // WHEN testee.markItemAsDone(item) @@ -316,7 +316,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDone_removesItemAfterThreeSeconds() { // GIVEN interactor.markItemAsDoneResult = .success(()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment") let group = TodoGroupViewModel(date: Date(), items: [item]) testee.items = [group] @@ -336,7 +336,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDone_whileDone_marksAsUndone() { // GIVEN interactor.markItemAsDoneResult = .success(()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment", overrideId: "override-1") item.markDoneState = .done // WHEN @@ -352,7 +352,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDone_undoBeforeRemoval_cancelsTimer() { // GIVEN interactor.markItemAsDoneResult = .success(()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment", overrideId: "override-1") let group = TodoGroupViewModel(date: Date(), items: [item]) testee.items = [group] @@ -374,7 +374,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markAsUndone_onError_changesStateBackToDone() { // GIVEN interactor.markItemAsDoneResult = .failure(NSError.internalError()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment", overrideId: "override-1") item.markDoneState = .done // WHEN @@ -388,7 +388,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markAsUndone_onError_showsSnackBar() { // GIVEN interactor.markItemAsDoneResult = .failure(NSError.internalError()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", overrideId: "override-1") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment", overrideId: "override-1") item.markDoneState = .done // WHEN @@ -402,8 +402,8 @@ class TodoListViewModelTests: CoreTestCase { func test_removeItem_removesEmptyGroups() { // GIVEN interactor.markItemAsDoneResult = .success(()) - let item1 = TodoItemViewModel.make(id: "1", plannableType: "assignment") - let item2 = TodoItemViewModel.make(id: "2") + let item1 = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment") + let item2 = TodoItemViewModel.make(plannableId: "2") let group1 = TodoGroupViewModel(date: Date(), items: [item1]) let group2 = TodoGroupViewModel(date: Date().addingTimeInterval(86400), items: [item2]) testee.items = [group1, group2] @@ -416,13 +416,13 @@ class TodoListViewModelTests: CoreTestCase { // THEN testScheduler.advance(by: .seconds(3)) XCTAssertEqual(testee.items.count, 1) - XCTAssertEqual(testee.items.first?.items.first?.id, "2") + XCTAssertEqual(testee.items.first?.items.first?.plannableId, "2") } func test_removeItem_setsStateToEmpty_whenLastItemRemoved() { // GIVEN interactor.markItemAsDoneResult = .success(()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment") let group = TodoGroupViewModel(date: Date(), items: [item]) testee.items = [group] testee.state = .data @@ -441,7 +441,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDone_whileLoading_ignoresAdditionalTaps() { // GIVEN interactor.markItemAsDoneResult = .success(()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment") // WHEN testee.markItemAsDone(item) @@ -464,7 +464,7 @@ class TodoListViewModelTests: CoreTestCase { // GIVEN TabBarBadgeCounts.todoListCount = 5 interactor.markItemAsDoneResult = .success(()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment") // WHEN testee.markItemAsDone(item) @@ -479,7 +479,7 @@ class TodoListViewModelTests: CoreTestCase { // GIVEN TabBarBadgeCounts.todoListCount = 3 interactor.markItemAsDoneResult = .success(()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment") item.markDoneState = .done // WHEN @@ -495,7 +495,7 @@ class TodoListViewModelTests: CoreTestCase { // GIVEN TabBarBadgeCounts.todoListCount = 0 interactor.markItemAsDoneResult = .success(()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment") // WHEN testee.markItemAsDone(item) @@ -511,7 +511,7 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDoneWithOptimisticUI_removesItemImmediately() { // GIVEN interactor.markItemAsDoneResult = .success(()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment") let group = TodoGroupViewModel(date: Date(), items: [item]) testee.items = [group] @@ -527,7 +527,7 @@ class TodoListViewModelTests: CoreTestCase { // GIVEN TabBarBadgeCounts.todoListCount = 5 interactor.markItemAsDoneResult = .success(()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment") + let item = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment") let group = TodoGroupViewModel(date: Date(), items: [item]) testee.items = [group] @@ -544,10 +544,10 @@ class TodoListViewModelTests: CoreTestCase { // GIVEN TabBarBadgeCounts.todoListCount = 5 interactor.markItemAsDoneResult = .failure(NSError.internalError()) - let item = TodoItemViewModel.make(id: "1", plannableType: "assignment", date: Date()) + let item = TodoItemViewModel.make(plannableId: "1", date: Date(), plannableType: "assignment") let group = TodoGroupViewModel(date: Date().startOfDay(), items: [item]) testee.items = [group] - interactor.todoGroupsSubject.send([group]) + interactor.todoGroups.send([group]) // WHEN testee.markItemAsDoneWithOptimisticUI(item) @@ -558,7 +558,7 @@ class TodoListViewModelTests: CoreTestCase { // THEN XCTAssertEqual(testee.items.count, 1) XCTAssertEqual(testee.items.first?.items.count, 1) - XCTAssertEqual(testee.items.first?.items.first?.id, "1") + XCTAssertEqual(testee.items.first?.items.first?.plannableId, "1") XCTAssertEqual(TabBarBadgeCounts.todoListCount, 5) XCTAssertNotNil(testee.snackBar.visibleSnack) } @@ -567,9 +567,9 @@ class TodoListViewModelTests: CoreTestCase { // GIVEN TabBarBadgeCounts.todoListCount = 3 interactor.markItemAsDoneResult = .success(()) - let item1 = TodoItemViewModel.make(id: "1", plannableType: "assignment") - let item2 = TodoItemViewModel.make(id: "2", plannableType: "assignment") - let item3 = TodoItemViewModel.make(id: "3", plannableType: "assignment") + let item1 = TodoItemViewModel.make(plannableId: "1", plannableType: "assignment") + let item2 = TodoItemViewModel.make(plannableId: "2", plannableType: "assignment") + let item3 = TodoItemViewModel.make(plannableId: "3", plannableType: "assignment") let group = TodoGroupViewModel(date: Date(), items: [item1, item2, item3]) testee.items = [group] @@ -589,12 +589,12 @@ class TodoListViewModelTests: CoreTestCase { // GIVEN TabBarBadgeCounts.todoListCount = 3 interactor.markItemAsDoneResult = .failure(NSError.internalError()) - let item1 = TodoItemViewModel.make(id: "1", plannableType: "assignment", date: Date()) - let item2 = TodoItemViewModel.make(id: "2", plannableType: "assignment", date: Date()) - let item3 = TodoItemViewModel.make(id: "3", plannableType: "assignment", date: Date()) + let item1 = TodoItemViewModel.make(plannableId: "1", date: Date(), plannableType: "assignment") + let item2 = TodoItemViewModel.make(plannableId: "2", date: Date(), plannableType: "assignment") + let item3 = TodoItemViewModel.make(plannableId: "3", date: Date(), plannableType: "assignment") let group = TodoGroupViewModel(date: Date().startOfDay(), items: [item1, item2, item3]) testee.items = [group] - interactor.todoGroupsSubject.send([group]) + interactor.todoGroups.send([group]) // WHEN testee.markItemAsDoneWithOptimisticUI(item1) @@ -614,14 +614,13 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDoneWithOptimisticUI_multipleConcurrentSwipes_mixedResults() { // GIVEN TabBarBadgeCounts.todoListCount = 3 - let item1 = TodoItemViewModel.make(id: "1", plannableType: "assignment", date: Date()) - let item2 = TodoItemViewModel.make(id: "2", plannableType: "assignment", date: Date()) - let item3 = TodoItemViewModel.make(id: "3", plannableType: "assignment", date: Date()) + let item1 = TodoItemViewModel.make(plannableId: "1", date: Date(), plannableType: "assignment") + let item2 = TodoItemViewModel.make(plannableId: "2", date: Date(), plannableType: "assignment") + let item3 = TodoItemViewModel.make(plannableId: "3", date: Date(), plannableType: "assignment") let group = TodoGroupViewModel(date: Date().startOfDay(), items: [item1, item2, item3]) testee.items = [group] - interactor.todoGroupsSubject.send([group]) + interactor.todoGroups.send([group]) - var callCount = 0 interactor.markItemAsDoneResult = .success(()) // WHEN - swipe all items @@ -642,7 +641,7 @@ class TodoListViewModelTests: CoreTestCase { // THEN - only item2 should be restored XCTAssertEqual(testee.items.count, 1) XCTAssertEqual(testee.items.first?.items.count, 1) - XCTAssertEqual(testee.items.first?.items.first?.id, "2") + XCTAssertEqual(testee.items.first?.items.first?.plannableId, "2") XCTAssertEqual(TabBarBadgeCounts.todoListCount, 1) } @@ -652,13 +651,13 @@ class TodoListViewModelTests: CoreTestCase { let today = Date().startOfDay() let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)! - let item1 = TodoItemViewModel.make(id: "1", date: today) - let item2 = TodoItemViewModel.make(id: "2", date: tomorrow) + let item1 = TodoItemViewModel.make(plannableId: "1", date: today) + let item2 = TodoItemViewModel.make(plannableId: "2", date: tomorrow) let group1 = TodoGroupViewModel(date: today, items: [item1]) let group2 = TodoGroupViewModel(date: tomorrow, items: [item2]) testee.items = [group1, group2] - interactor.todoGroupsSubject.send([group1, group2]) + interactor.todoGroups.send([group1, group2]) // WHEN testee.markItemAsDoneWithOptimisticUI(item1) @@ -667,9 +666,9 @@ class TodoListViewModelTests: CoreTestCase { // THEN XCTAssertEqual(testee.items.count, 2) XCTAssertEqual(testee.items.first?.date, today) - XCTAssertEqual(testee.items.first?.items.first?.id, "1") + XCTAssertEqual(testee.items.first?.items.first?.plannableId, "1") XCTAssertEqual(testee.items.last?.date, tomorrow) - XCTAssertEqual(testee.items.last?.items.first?.id, "2") + XCTAssertEqual(testee.items.last?.items.first?.plannableId, "2") } func test_markItemAsDoneWithOptimisticUI_recreatesGroupIfNeeded() { @@ -678,13 +677,13 @@ class TodoListViewModelTests: CoreTestCase { let today = Date().startOfDay() let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)! - let item1 = TodoItemViewModel.make(id: "1", date: today) - let item2 = TodoItemViewModel.make(id: "2", date: tomorrow) + let item1 = TodoItemViewModel.make(plannableId: "1", date: today) + let item2 = TodoItemViewModel.make(plannableId: "2", date: tomorrow) let group1 = TodoGroupViewModel(date: today, items: [item1]) let group2 = TodoGroupViewModel(date: tomorrow, items: [item2]) testee.items = [group1, group2] - interactor.todoGroupsSubject.send([group1, group2]) + interactor.todoGroups.send([group1, group2]) // WHEN - remove all items from first group testee.markItemAsDoneWithOptimisticUI(item1) @@ -700,11 +699,11 @@ class TodoListViewModelTests: CoreTestCase { func test_markItemAsDoneWithOptimisticUI_stateTransition_emptyToData() { // GIVEN interactor.markItemAsDoneResult = .failure(NSError.internalError()) - let item = TodoItemViewModel.make(id: "1", date: Date()) + let item = TodoItemViewModel.make(plannableId: "1", date: Date()) let group = TodoGroupViewModel(date: Date().startOfDay(), items: [item]) testee.items = [group] testee.state = .data - interactor.todoGroupsSubject.send([group]) + interactor.todoGroups.send([group]) // WHEN testee.markItemAsDoneWithOptimisticUI(item) diff --git a/Student/Widgets/Common/Model/Routes.swift b/Student/Widgets/Common/Model/Routes.swift index 668af60d31..aecb0bc70c 100644 --- a/Student/Widgets/Common/Model/Routes.swift +++ b/Student/Widgets/Common/Model/Routes.swift @@ -35,9 +35,9 @@ extension TodoItemViewModel { var route: URL { var url = switch type { case .calendar_event: - URL.todoWidgetRoute("todo-widget/calendar_events/\(id)") + URL.todoWidgetRoute("todo-widget/calendar_events/\(plannableId)") case .planner_note: - URL.todoWidgetRoute("todo-widget/planner-notes/\(id)") + URL.todoWidgetRoute("todo-widget/planner-notes/\(plannableId)") default: htmlURL?.appendingOrigin("todo-widget") ?? .appEmptyRoute } diff --git a/Student/Widgets/TodoWidget/Model/TodoModel.swift b/Student/Widgets/TodoWidget/Model/TodoModel.swift index 94ddcdc333..d877ab254e 100644 --- a/Student/Widgets/TodoWidget/Model/TodoModel.swift +++ b/Student/Widgets/TodoWidget/Model/TodoModel.swift @@ -67,7 +67,7 @@ extension TodoModel { static func make(count: Int = 5) -> TodoModel { let items = [ TodoItemViewModel( - id: "1", + plannableId: "1", type: .assignment, date: Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: Date.now)!, title: String(localized: "Research Paper Draft"), @@ -78,7 +78,7 @@ extension TodoModel { icon: .assignmentLine ), TodoItemViewModel( - id: "2", + plannableId: "2", type: .discussion_topic, date: Calendar.current.date(bySettingHour: 12, minute: 0, second: 0, of: Date.now)!, title: String(localized: "Chapter 5 Discussion"), @@ -89,7 +89,7 @@ extension TodoModel { icon: .discussionLine ), TodoItemViewModel( - id: "3", + plannableId: "3", type: .calendar_event, date: Calendar.current.date(bySettingHour: 15, minute: 0, second: 0, of: Date.now)!, title: String(localized: "Guest Lecture Series"), @@ -100,7 +100,7 @@ extension TodoModel { icon: .calendarMonthLine ), TodoItemViewModel( - id: "4", + plannableId: "4", type: .planner_note, date: Calendar.current.date(bySettingHour: 10, minute: 0, second: 0, of: Date.now.addDays(3))!, title: String(localized: "Review study materials"), @@ -111,7 +111,7 @@ extension TodoModel { icon: .noteLine ), TodoItemViewModel( - id: "5", + plannableId: "5", type: .quiz, date: Calendar.current.date(bySettingHour: 14, minute: 0, second: 0, of: Date.now.addDays(3))!, title: String(localized: "Unit 3 Quiz"), @@ -122,7 +122,7 @@ extension TodoModel { icon: .quizLine ), TodoItemViewModel( - id: "6", + plannableId: "6", type: .assignment, date: Calendar.current.date(bySettingHour: 16, minute: 0, second: 0, of: Date.now.addDays(3))!, title: String(localized: "Lab Report Submission"), @@ -133,7 +133,7 @@ extension TodoModel { icon: .assignmentLine ), TodoItemViewModel( - id: "7", + plannableId: "7", type: .wiki_page, date: Calendar.current.date(bySettingHour: 18, minute: 0, second: 0, of: Date.now.addDays(3))!, title: String(localized: "Course Syllabus"), From 1fd77d0cc28de44e7e83911cb4c10316a97257ba Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 21 Oct 2025 14:31:33 +0200 Subject: [PATCH 40/49] Reset view identity on restoration and force refresh. --- .../Core/Features/Todos/ViewModel/TodoItemViewModel.swift | 8 +++++++- .../Core/Features/Todos/ViewModel/TodoListViewModel.swift | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index 7d6491dfbd..5da75ebbf0 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -25,7 +25,9 @@ public enum MarkDoneState: Equatable { case done } -public class TodoItemViewModel: /*Identifiable,*/ Equatable, Comparable, ObservableObject { +public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableObject { + /// This is the view identity that might change. Don't use this for business logic. + public private(set) var id: String = UUID.string public let type: PlannableType public let date: Date public let dateText: String @@ -103,6 +105,10 @@ public class TodoItemViewModel: /*Identifiable,*/ Equatable, Comparable, Observa self.overrideId = overrideId } + public func resetViewIdentity() { + id = UUID.string + } + /// Helper function to determine the context name for a Todo item. /// - Parameters: /// - isCourseNameNickname: Whether the course name is a user-given nickname. diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index a6760faae2..1f2f0b7c5e 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -148,6 +148,8 @@ public class TodoListViewModel: ObservableObject { .first(where: { $0.plannableId == itemId }) else { return } + // We need to reset the view's ID otherwise the previous state of the cell (swiped left) will be restored. + itemToRestore.resetViewIdentity() withAnimation { var updatedItems = items From cfc57316e0d66b7c253e8b287736114f70ec0882 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 21 Oct 2025 15:29:45 +0200 Subject: [PATCH 41/49] Prevent scrolling while swipe is in action. --- .../Utils}/SwipeToRemove.swift | 45 ++++++++++++++----- .../Todos/View/TodoListItemCell.swift | 10 +++-- .../Features/Todos/View/TodoListScreen.swift | 5 ++- 3 files changed, 43 insertions(+), 17 deletions(-) rename Core/Core/Common/CommonUI/{SwiftUIViews => InstUI/Utils}/SwipeToRemove.swift (74%) diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/SwipeToRemove.swift b/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift similarity index 74% rename from Core/Core/Common/CommonUI/SwiftUIViews/SwipeToRemove.swift rename to Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift index 1e78d73dab..3a95cc7aec 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/SwipeToRemove.swift +++ b/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift @@ -19,23 +19,44 @@ import SwiftUI public extension View { - func swipeToRemove( + + /// Adds a swipe-to-remove gesture that reveals an action view when swiping left. + /// + /// The gesture requires swiping past a threshold to trigger the action. + /// Visual and haptic feedback is provided when the threshold is crossed. + /// Once the action is triggered, the view remains in the fully revealed position + /// and it's the caller's responsibility to remove the cell from the view hierarcy. + /// + /// - Parameters: + /// - backgroundColor: The background color revealed behind the content during the swipe. + /// - isSwiping: Optional binding that tracks whether a swipe gesture is currently active. Use this to disable scrolling or other gestures while swiping. + /// - onSwipe: Closure called when the swipe action is completed. + /// - label: The view displayed in the revealed area during the swipe. + func swipeToRemove( backgroundColor: Color, + isSwiping: Binding? = nil, onSwipe: @escaping () -> Void, - @ViewBuilder actionView: @escaping () -> ActionView + @ViewBuilder label: @escaping () -> Label ) -> some View { modifier(SwipeToRemoveModifier( backgroundColor: backgroundColor, + isSwiping: isSwiping, onSwipe: onSwipe, - actionView: actionView + label: label )) } } -private struct SwipeToRemoveModifier: ViewModifier { +private struct SwipeToRemoveModifier: ViewModifier { let backgroundColor: Color + let isSwiping: Binding? let onSwipe: () -> Void - let actionView: () -> ActionView + let label: () -> Label + + // MARK: - Gesture Properties + private let minimumDragDistance: CGFloat = 20 + /// The ratio of cell width that must be swiped to trigger the action (0.8 = 80% of cell width). + private let actionThresholdRatio: CGFloat = 0.8 // MARK: - Layout & Sizing @State private var cellContentOffset: CGFloat = 0 @@ -60,21 +81,22 @@ private struct SwipeToRemoveModifier: ViewModifier { } .onWidthChange { width in cellWidth = width - actionThreshold = cellWidth * 0.8 + actionThreshold = cellWidth * actionThresholdRatio } .contentShape(Rectangle()) // If this is a simple gesture and the cell is a button then swiping won't work .simultaneousGesture( - DragGesture() + DragGesture(minimumDistance: minimumDragDistance) .onChanged(handleDragChanged) - .onEnded(handleDragEnded) + .onEnded(handleDragEnded), + isEnabled: !isActionInvoked ) } private var swipeBackground: some View { backgroundColor .overlay(alignment: .trailing) { - actionView() + label() .onWidthChange { width in actionViewWidth = width actionViewOffset = width @@ -87,13 +109,12 @@ private struct SwipeToRemoveModifier: ViewModifier { // MARK: - Drag In Progress private func handleDragChanged(_ value: DragGesture.Value) { - if isActionInvoked { return } - let translation = value.translation.width // We are only interested in swipes to the left guard translation < 0 else { return } + isSwiping?.wrappedValue = true hapticGenerator.prepare() cellContentOffset = max(translation, -cellWidth) @@ -136,7 +157,7 @@ private struct SwipeToRemoveModifier: ViewModifier { // MARK: - Drag Finish private func handleDragEnded(_: DragGesture.Value) { - if isActionInvoked { return } + isSwiping?.wrappedValue = false if isActionThresholdReached { animateToOpenedState() diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index a7c79112cb..ba1140857d 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -26,6 +26,7 @@ struct TodoListItemCell: View { let onTap: (_ item: TodoItemViewModel, _ viewController: WeakViewController) -> Void let onMarkAsDone: (_ item: TodoItemViewModel) -> Void let onSwipeMarkAsDone: (_ item: TodoItemViewModel) -> Void + let isSwiping: Binding? var body: some View { VStack(spacing: 0) { @@ -39,14 +40,15 @@ struct TodoListItemCell: View { .paddingStyle(.leading, .cellAccessoryPadding) } .padding(.vertical, 8) - } + .paddingStyle(.trailing, .standard) .background(.backgroundLightest) .accessibilityElement(children: .combine) .swipeToRemove( backgroundColor: .backgroundSuccess, + isSwiping: isSwiping, onSwipe: { onSwipeMarkAsDone(item) }, - actionView: { swipeActionView } + label: { swipeActionView } ) } } @@ -89,8 +91,8 @@ struct TodoListItemCell: View { #Preview { VStack(spacing: 0) { - TodoListItemCell(item: .makeShortText(), onTap: { _, _ in }, onMarkAsDone: { _ in }, onSwipeMarkAsDone: { _ in }) - TodoListItemCell(item: .makeLongText(), onTap: { _, _ in }, onMarkAsDone: { _ in }, onSwipeMarkAsDone: { _ in }) + TodoListItemCell(item: .makeShortText(), onTap: { _, _ in }, onMarkAsDone: { _ in }, onSwipeMarkAsDone: { _ in }, isSwiping: nil) + TodoListItemCell(item: .makeLongText(), onTap: { _, _ in }, onMarkAsDone: { _ in }, onSwipeMarkAsDone: { _ in }, isSwiping: nil) } .background(Color.backgroundLightest) } diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 80c1f29b74..880ac5a5f3 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -24,6 +24,7 @@ public struct TodoListScreen: View { @ObservedObject var viewModel: TodoListViewModel /// The sticky section header grows horizontally, so we need to increase paddings here not to let the header overlap the cell content. @ScaledMetric private var uiScale: CGFloat = 1 + @State private var isCellSwiping = false public init(viewModel: TodoListViewModel) { self.viewModel = viewModel @@ -46,6 +47,7 @@ public struct TodoListScreen: View { } } .clipped() + .scrollDisabled(isCellSwiping) .navigationBarItems(leading: profileMenuButton) .snackBar(viewModel: viewModel.snackBar) } @@ -59,7 +61,8 @@ public struct TodoListScreen: View { item: item, onTap: viewModel.didTapItem, onMarkAsDone: viewModel.markItemAsDone, - onSwipeMarkAsDone: viewModel.markItemAsDoneWithOptimisticUI + onSwipeMarkAsDone: viewModel.markItemAsDoneWithOptimisticUI, + isSwiping: $isCellSwiping ) .padding(.leading, leadingPadding) .transition(.asymmetric( From 3bf743c32a252667249c300823710f6386f424e3 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 21 Oct 2025 16:39:43 +0200 Subject: [PATCH 42/49] Fine tune gesture properties. --- .../CommonUI/InstUI/Utils/SwipeToRemove.swift | 15 ++++++++---- .../Foundation/CGSizeExtensions.swift | 6 +++-- .../Foundation/CGSizeExtensionsTests.swift | 24 +++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift b/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift index 3a95cc7aec..8eb319e7d2 100644 --- a/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift +++ b/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift @@ -54,7 +54,7 @@ private struct SwipeToRemoveModifier: ViewModifier { let label: () -> Label // MARK: - Gesture Properties - private let minimumDragDistance: CGFloat = 20 + private let minimumDragDistance: CGFloat = 10 /// The ratio of cell width that must be swiped to trigger the action (0.8 = 80% of cell width). private let actionThresholdRatio: CGFloat = 0.8 @@ -109,14 +109,19 @@ private struct SwipeToRemoveModifier: ViewModifier { // MARK: - Drag In Progress private func handleDragChanged(_ value: DragGesture.Value) { - let translation = value.translation.width + let horizontalTranslation = value.translation.width - // We are only interested in swipes to the left - guard translation < 0 else { return } + guard value.translation.isHorizontalSwipe else { return } + + guard value.translation.isSwipingLeft else { + animateToClosedState() + return + } isSwiping?.wrappedValue = true + hapticGenerator.prepare() - cellContentOffset = max(translation, -cellWidth) + cellContentOffset = max(horizontalTranslation, -cellWidth) handleActionThresholdCrossing() updateActionViewPosition() diff --git a/Core/Core/Common/Extensions/Foundation/CGSizeExtensions.swift b/Core/Core/Common/Extensions/Foundation/CGSizeExtensions.swift index 7cf88e4a1d..30575794d6 100644 --- a/Core/Core/Common/Extensions/Foundation/CGSizeExtensions.swift +++ b/Core/Core/Common/Extensions/Foundation/CGSizeExtensions.swift @@ -18,6 +18,8 @@ import Foundation -public extension CGSize { - var isZero: Bool { self == .zero } +extension CGSize { + public var isZero: Bool { self == .zero } + public var isSwipingLeft: Bool { width < 0 } + public var isHorizontalSwipe: Bool { abs(width) > abs(height) } } diff --git a/Core/CoreTests/Common/Extensions/Foundation/CGSizeExtensionsTests.swift b/Core/CoreTests/Common/Extensions/Foundation/CGSizeExtensionsTests.swift index 0a799e67f1..76123d6313 100644 --- a/Core/CoreTests/Common/Extensions/Foundation/CGSizeExtensionsTests.swift +++ b/Core/CoreTests/Common/Extensions/Foundation/CGSizeExtensionsTests.swift @@ -27,4 +27,28 @@ class CGSizeExtensionsTests: CoreTestCase { XCTAssertFalse(CGSize(width: 1, height: 1).isZero) } + + func test_isSwipingLeft() { + XCTAssertTrue(CGSize(width: -10, height: 0).isSwipingLeft) + XCTAssertTrue(CGSize(width: -1, height: 5).isSwipingLeft) + XCTAssertTrue(CGSize(width: -100, height: -50).isSwipingLeft) + + XCTAssertFalse(CGSize(width: 0, height: 0).isSwipingLeft) + XCTAssertFalse(CGSize(width: 10, height: 0).isSwipingLeft) + XCTAssertFalse(CGSize(width: 5, height: -3).isSwipingLeft) + } + + func test_isHorizontalSwipe() { + XCTAssertTrue(CGSize(width: 10, height: 5).isHorizontalSwipe) + XCTAssertTrue(CGSize(width: -10, height: 5).isHorizontalSwipe) + XCTAssertTrue(CGSize(width: 10, height: -5).isHorizontalSwipe) + XCTAssertTrue(CGSize(width: -10, height: -5).isHorizontalSwipe) + XCTAssertTrue(CGSize(width: 50, height: 0).isHorizontalSwipe) + + XCTAssertFalse(CGSize(width: 5, height: 10).isHorizontalSwipe) + XCTAssertFalse(CGSize(width: -5, height: 10).isHorizontalSwipe) + XCTAssertFalse(CGSize(width: 5, height: -10).isHorizontalSwipe) + XCTAssertFalse(CGSize(width: 0, height: 50).isHorizontalSwipe) + XCTAssertFalse(CGSize(width: 0, height: 0).isHorizontalSwipe) + } } From b847f52cb6413d5e59497557da26a16520ffda88 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 21 Oct 2025 17:09:18 +0200 Subject: [PATCH 43/49] Add accessibility support for mark as done feature. refs: MBL-19374 builds: Student affects: Student release note: Added mark-as-done feature to new To-do screen. test plan: See ticket. Co-Authored-By: Claude --- .../CommonUI/InstUI/Utils/SwipeToRemove.swift | 1 + .../Todos/View/TodoListItemCell.swift | 8 + .../Todos/ViewModel/TodoItemViewModel.swift | 11 ++ .../Todos/ViewModel/TodoListViewModel.swift | 8 +- Core/Core/Resources/Localizable.xcstrings | 14 +- .../Todos/Model/TodoInteractorLiveTests.swift | 180 ++++++++++++++++++ 6 files changed, 220 insertions(+), 2 deletions(-) diff --git a/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift b/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift index 8eb319e7d2..d208f496b3 100644 --- a/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift +++ b/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift @@ -104,6 +104,7 @@ private struct SwipeToRemoveModifier: ViewModifier { .offset(x: actionViewOffset) } .animation(.smooth, value: actionViewOffset) + .accessibilityHidden(true) } // MARK: - Drag In Progress diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index ba1140857d..9cab4758ec 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -38,12 +38,20 @@ struct TodoListItemCell: View { checkboxButton .paddingStyle(.leading, .cellAccessoryPadding) + .accessibilityHidden(true) } .padding(.vertical, 8) } .paddingStyle(.trailing, .standard) .background(.backgroundLightest) .accessibilityElement(children: .combine) + .accessibilityActions { + if let label = item.markAsDoneAccessibilityLabel { + Button(label) { + onMarkAsDone(item) + } + } + } .swipeToRemove( backgroundColor: .backgroundSuccess, isSwiping: isSwiping, diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index 5da75ebbf0..4ee739c65f 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -109,6 +109,17 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO id = UUID.string } + public var markAsDoneAccessibilityLabel: String? { + switch markDoneState { + case .notDone: + return String(localized: "Mark as done", bundle: .core) + case .loading: + return nil + case .done: + return String(localized: "Mark as not done", bundle: .core) + } + } + /// Helper function to determine the context name for a Todo item. /// - Parameters: /// - isCourseNameNickname: Whether the course name is a user-given nickname. diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index 1f2f0b7c5e..0008054b1e 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -205,6 +205,9 @@ public class TodoListViewModel: ObservableObject { guard let item else { return } item.markDoneState = .notDone TabBarBadgeCounts.todoListCount += 1 + + let announcement = String(localized: "\(item.title), marked as not done", bundle: .core) + UIAccessibility.announce(announcement) } .store(in: &subscriptions) } @@ -235,7 +238,7 @@ public class TodoListViewModel: ObservableObject { private func handleMarkAsUndoneError(_ item: TodoItemViewModel, _ error: Error) { item.markDoneState = .done - snackBar.showSnack(String(localized: "Failed to mark item as undone", bundle: .core)) + snackBar.showSnack(String(localized: "Failed to mark item as not done", bundle: .core)) } private func removeItem(_ item: TodoItemViewModel) { @@ -248,5 +251,8 @@ public class TodoListViewModel: ObservableObject { if items.isEmpty { state = .empty } + + let announcement = String(localized: "\(item.title), marked as done", bundle: .core) + UIAccessibility.announce(announcement) } } diff --git a/Core/Core/Resources/Localizable.xcstrings b/Core/Core/Resources/Localizable.xcstrings index a313012efc..bbf67c1d32 100644 --- a/Core/Core/Resources/Localizable.xcstrings +++ b/Core/Core/Resources/Localizable.xcstrings @@ -6432,6 +6432,12 @@ } } } + }, + "%@, marked as done" : { + + }, + "%@, marked as not done" : { + }, "%@, unread" : { "comment" : "added to conversation label when unread", @@ -148473,7 +148479,7 @@ "Failed to mark item as done" : { }, - "Failed to mark item as undone" : { + "Failed to mark item as not done" : { }, "Failed to publish all Modules and all Items" : { @@ -216427,6 +216433,9 @@ } } } + }, + "Mark as done" : { + }, "Mark as Done" : { "localizations" : { @@ -216683,6 +216692,9 @@ } } } + }, + "Mark as not done" : { + }, "Mark as read" : { "localizations" : { diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift index 0ff1b54e30..5f54d6af4d 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift @@ -219,6 +219,186 @@ class TodoInteractorLiveTests: CoreTestCase { XCTAssertEqual(TabBarBadgeCounts.todoListCount, 3) } + // MARK: - Mark Item as Done Tests + + func testMarkItemAsDone_createsNewOverride_whenNoExistingOverride() { + // Given + let plannable = Plannable.save( + APIPlannable.make(plannable_id: ID("123"), plannable_type: "assignment"), + userId: nil, + in: databaseClient + ) + let item = TodoItemViewModel(plannable)! + + let createRequest = CreatePlannerOverrideRequest( + body: .init( + plannable_type: "assignment", + plannable_id: "123", + marked_complete: true + ) + ) + let mockResponse = APIPlannerOverride.make( + id: "override-456", + plannable_type: "assignment", + plannable_id: ID("123"), + marked_complete: true + ) + api.mock(createRequest, value: mockResponse) + + // When + XCTAssertFinish(testee.markItemAsDone(item, done: true)) + + // Then + databaseClient.refresh() + XCTAssertEqual(plannable.isMarkedComplete, true) + XCTAssertEqual(plannable.plannerOverrideId, "override-456") + XCTAssertEqual(item.overrideId, "override-456") + } + + func testMarkItemAsDone_updatesExistingOverride_whenOverrideExists() { + // Given + let plannable = Plannable.save( + APIPlannable.make( + planner_override: .make(id: "override-123", marked_complete: true), + plannable_id: ID("123"), + plannable_type: "assignment" + ), + userId: nil, + in: databaseClient + ) + let item = TodoItemViewModel(plannable)! + + let updateRequest = UpdatePlannerOverrideRequest( + overrideId: "override-123", + body: .init(marked_complete: false) + ) + api.mock(updateRequest, value: APINoContent()) + + // When + XCTAssertFinish(testee.markItemAsDone(item, done: false)) + + // Then + databaseClient.refresh() + XCTAssertEqual(plannable.isMarkedComplete, false) + XCTAssertEqual(plannable.plannerOverrideId, "override-123") + XCTAssertEqual(item.overrideId, "override-123") + } + + func testMarkItemAsDone_handlesError_whenAPICallFails() { + // Given + let plannable = Plannable.save( + APIPlannable.make(plannable_id: ID("123"), plannable_type: "assignment"), + userId: nil, + in: databaseClient + ) + let item = TodoItemViewModel(plannable)! + + let createRequest = CreatePlannerOverrideRequest( + body: .init( + plannable_type: "assignment", + plannable_id: "123", + marked_complete: true + ) + ) + api.mock(createRequest, error: NSError.instructureError("Network error")) + + // When + XCTAssertFailure(testee.markItemAsDone(item, done: true)) + + // Then + databaseClient.refresh() + XCTAssertEqual(plannable.isMarkedComplete, false) + XCTAssertNil(plannable.plannerOverrideId) + XCTAssertNil(item.overrideId) + } + + func testMarkItemAsDone_updatesItemOverrideId_afterSuccessfulCreation() { + // Given + let plannable = Plannable.save( + APIPlannable.make(plannable_id: ID("123"), plannable_type: "quiz"), + userId: nil, + in: databaseClient + ) + let item = TodoItemViewModel(plannable)! + XCTAssertNil(item.overrideId) + + let createRequest = CreatePlannerOverrideRequest( + body: .init( + plannable_type: "quiz", + plannable_id: "123", + marked_complete: true + ) + ) + let mockResponse = APIPlannerOverride.make( + id: "new-override-789", + plannable_type: "quiz", + plannable_id: ID("123"), + marked_complete: true + ) + api.mock(createRequest, value: mockResponse) + + // When + XCTAssertFinish(testee.markItemAsDone(item, done: true)) + + // Then + XCTAssertEqual(item.overrideId, "new-override-789") + } + + func testMarkItemAsDone_marksItemAsDone_withDoneTrue() { + // Given + let plannable = Plannable.save( + APIPlannable.make(plannable_id: ID("123"), plannable_type: "assignment"), + userId: nil, + in: databaseClient + ) + let item = TodoItemViewModel(plannable)! + XCTAssertFalse(plannable.isMarkedComplete) + + let createRequest = CreatePlannerOverrideRequest( + body: .init( + plannable_type: "assignment", + plannable_id: "123", + marked_complete: true + ) + ) + api.mock(createRequest, value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) + + // When + XCTAssertFinish(testee.markItemAsDone(item, done: true)) + + // Then + databaseClient.refresh() + XCTAssertTrue(plannable.isMarkedComplete) + } + + func testMarkItemAsDone_marksItemAsUndone_withDoneFalse() { + // Given + let plannable = Plannable.save( + APIPlannable.make( + planner_override: .make(id: "override-123", marked_complete: true), + plannable_id: ID("123"), + plannable_type: "assignment" + ), + userId: nil, + in: databaseClient + ) + let item = TodoItemViewModel(plannable)! + XCTAssertTrue(plannable.isMarkedComplete) + + let updateRequest = UpdatePlannerOverrideRequest( + overrideId: "override-123", + body: .init(marked_complete: false) + ) + api.mock(updateRequest, value: APINoContent()) + + // When + XCTAssertFinish(testee.markItemAsDone(item, done: false)) + + // Then + databaseClient.refresh() + XCTAssertFalse(plannable.isMarkedComplete) + } + // MARK: - Helpers private func mockCourses(_ courses: [APICourse]) { From 44d81343c2bcac0094a13853b622f2726fa46dba Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 21 Oct 2025 18:42:45 +0200 Subject: [PATCH 44/49] Use tap gesture instead of button to avoid gesture recognizer conflicts. --- .../Todos/View/TodoListItemCell.swift | 32 +++++++++++-------- .../ViewModel/TodoListViewModelTests.swift | 2 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index 9cab4758ec..26929db154 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -30,21 +30,23 @@ struct TodoListItemCell: View { var body: some View { VStack(spacing: 0) { - Button { - onTap(item, viewController) - } label: { - HStack(spacing: 0) { - TodoItemContentView(item: item, isCompactLayout: false) + HStack(spacing: 0) { + TodoItemContentView(item: item, isCompactLayout: false) - checkboxButton - .paddingStyle(.leading, .cellAccessoryPadding) - .accessibilityHidden(true) - } - .padding(.vertical, 8) + checkboxButton + .paddingStyle(.leading, .cellAccessoryPadding) + .accessibilityHidden(true) } + .padding(.vertical, 8) .paddingStyle(.trailing, .standard) .background(.backgroundLightest) + .contentShape(Rectangle()) + .onTapGesture { + guard isSwiping?.wrappedValue != true else { return } + onTap(item, viewController) + } .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) .accessibilityActions { if let label = item.markAsDoneAccessibilityLabel { Button(label) { @@ -74,9 +76,7 @@ struct TodoListItemCell: View { @ViewBuilder private var checkboxButton: some View { - Button { - onMarkAsDone(item) - } label: { + ZStack { switch item.markDoneState { case .notDone: InstUI.Checkbox(isSelected: false) @@ -87,9 +87,13 @@ struct TodoListItemCell: View { InstUI.Checkbox(isSelected: true) } } - .buttonStyle(.plain) .frame(width: 44, height: 44) .tint(Color(Brand.shared.primary)) + .contentShape(Rectangle()) + .onTapGesture { + guard isSwiping?.wrappedValue != true else { return } + onMarkAsDone(item) + } .identifier("to-do.list.\(item.plannableId).checkbox") } diff --git a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift index 69d11c66e3..0754c99df7 100644 --- a/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift +++ b/Core/CoreTests/Features/Todos/ViewModel/TodoListViewModelTests.swift @@ -506,7 +506,7 @@ class TodoListViewModelTests: CoreTestCase { XCTAssertEqual(item.markDoneState, .done) } - // MARK: - Optimistic UI Tests + // MARK: - Swipe to done func test_markItemAsDoneWithOptimisticUI_removesItemImmediately() { // GIVEN From 87977d95024278d743cb1b6a1d5965ddbef01ce9 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 21 Oct 2025 19:06:09 +0200 Subject: [PATCH 45/49] Tweak gesture recognizer to better separate vertical and horizontal gestures. --- .../CommonUI/InstUI/Utils/SwipeToRemove.swift | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift b/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift index d208f496b3..979ce50350 100644 --- a/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift +++ b/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift @@ -54,7 +54,6 @@ private struct SwipeToRemoveModifier: ViewModifier { let label: () -> Label // MARK: - Gesture Properties - private let minimumDragDistance: CGFloat = 10 /// The ratio of cell width that must be swiped to trigger the action (0.8 = 80% of cell width). private let actionThresholdRatio: CGFloat = 0.8 @@ -71,6 +70,8 @@ private struct SwipeToRemoveModifier: ViewModifier { @State private var isActionThresholdReached = false /// Becomes true after the action has been invoked to disable further drag gestures @State private var isActionInvoked = false + /// Set on first gesture update to determine if we should process this gesture + @State private var isStartedAsHorizontalGesture: Bool? private let hapticGenerator = UIImpactFeedbackGenerator(style: .medium) @@ -86,7 +87,7 @@ private struct SwipeToRemoveModifier: ViewModifier { .contentShape(Rectangle()) // If this is a simple gesture and the cell is a button then swiping won't work .simultaneousGesture( - DragGesture(minimumDistance: minimumDragDistance) + DragGesture() .onChanged(handleDragChanged) .onEnded(handleDragEnded), isEnabled: !isActionInvoked @@ -110,19 +111,18 @@ private struct SwipeToRemoveModifier: ViewModifier { // MARK: - Drag In Progress private func handleDragChanged(_ value: DragGesture.Value) { - let horizontalTranslation = value.translation.width - - guard value.translation.isHorizontalSwipe else { return } - - guard value.translation.isSwipingLeft else { - animateToClosedState() - return + if isStartedAsHorizontalGesture == nil { + isStartedAsHorizontalGesture = value.translation.isHorizontalSwipe } + guard isStartedAsHorizontalGesture == true, + value.translation.isSwipingLeft + else { return } + isSwiping?.wrappedValue = true hapticGenerator.prepare() - cellContentOffset = max(horizontalTranslation, -cellWidth) + cellContentOffset = max(value.translation.width, -cellWidth) handleActionThresholdCrossing() updateActionViewPosition() @@ -163,6 +163,9 @@ private struct SwipeToRemoveModifier: ViewModifier { // MARK: - Drag Finish private func handleDragEnded(_: DragGesture.Value) { + defer { isStartedAsHorizontalGesture = nil } + guard isStartedAsHorizontalGesture == true else { return } + isSwiping?.wrappedValue = false if isActionThresholdReached { From bdb91669676395b7bfe628d54ffb44f674ec25f7 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 22 Oct 2025 09:04:23 +0200 Subject: [PATCH 46/49] Add analytics events. --- .../Features/Todos/Model/TodoInteractor.swift | 2 + .../Todos/Model/TodoInteractorLiveTests.swift | 82 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index 55c709fe75..0deb924f8c 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -88,6 +88,8 @@ public final class TodoInteractorLive: TodoInteractor { return useCase.fetchWithFuture(environment: env) .map { [weak self] _ in self?.updateOverrideId(for: item) + let eventName = done ? "todo_item_marked_done" : "todo_item_marked_undone" + Analytics.shared.logEvent(eventName) return () } .eraseToAnyPublisher() diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift index 5f54d6af4d..3ecded8552 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift @@ -24,6 +24,7 @@ import Combine class TodoInteractorLiveTests: CoreTestCase { private var testee: TodoInteractorLive! + private var mockAnalyticsHandler: MockAnalyticsHandler! private static let mockDate = Date.make(year: 2025, month: 1, day: 15, hour: 12) // MARK: - Setup and teardown @@ -32,11 +33,14 @@ class TodoInteractorLiveTests: CoreTestCase { super.setUp() Clock.mockNow(Self.mockDate) environment.currentSession = LoginSession.make(userID: "1") + mockAnalyticsHandler = MockAnalyticsHandler() + Analytics.shared.handler = mockAnalyticsHandler testee = TodoInteractorLive(env: environment) } override func tearDown() { testee = nil + Analytics.shared.handler = nil Clock.reset() super.tearDown() } @@ -399,6 +403,84 @@ class TodoInteractorLiveTests: CoreTestCase { XCTAssertFalse(plannable.isMarkedComplete) } + // MARK: - Analytics Tests + + func testMarkItemAsDone_logsAnalyticsEvent_whenMarkingAsDone() { + // Given + let plannable = Plannable.save( + APIPlannable.make(plannable_id: ID("123"), plannable_type: "assignment"), + userId: nil, + in: databaseClient + ) + let item = TodoItemViewModel(plannable)! + + let createRequest = CreatePlannerOverrideRequest( + body: .init( + plannable_type: "assignment", + plannable_id: "123", + marked_complete: true + ) + ) + api.mock(createRequest, value: APIPlannerOverride.make(id: "override-1", marked_complete: true)) + + // When + XCTAssertFinish(testee.markItemAsDone(item, done: true)) + + // Then + XCTAssertEqual(mockAnalyticsHandler.lastEvent, "todo_item_marked_done") + } + + func testMarkItemAsDone_logsAnalyticsEvent_whenMarkingAsUndone() { + // Given + let plannable = Plannable.save( + APIPlannable.make( + planner_override: .make(id: "override-123", marked_complete: true), + plannable_id: ID("123"), + plannable_type: "assignment" + ), + userId: nil, + in: databaseClient + ) + let item = TodoItemViewModel(plannable)! + + let updateRequest = UpdatePlannerOverrideRequest( + overrideId: "override-123", + body: .init(marked_complete: false) + ) + api.mock(updateRequest, value: APINoContent()) + + // When + XCTAssertFinish(testee.markItemAsDone(item, done: false)) + + // Then + XCTAssertEqual(mockAnalyticsHandler.lastEvent, "todo_item_marked_undone") + } + + func testMarkItemAsDone_doesNotLogAnalytics_whenAPICallFails() { + // Given + let plannable = Plannable.save( + APIPlannable.make(plannable_id: ID("123"), plannable_type: "assignment"), + userId: nil, + in: databaseClient + ) + let item = TodoItemViewModel(plannable)! + + let createRequest = CreatePlannerOverrideRequest( + body: .init( + plannable_type: "assignment", + plannable_id: "123", + marked_complete: true + ) + ) + api.mock(createRequest, error: NSError.instructureError("Network error")) + + // When + XCTAssertFailure(testee.markItemAsDone(item, done: true)) + + // Then + XCTAssertNil(mockAnalyticsHandler.lastEvent) + } + // MARK: - Helpers private func mockCourses(_ courses: [APICourse]) { From d82336246a0e2098caf2e7a2afa8ddc2af1ca5d6 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 22 Oct 2025 09:45:58 +0200 Subject: [PATCH 47/49] Code cleanup. --- .../Todos/ViewModel/TodoItemViewModel.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift index 4ee739c65f..a5fdab9e87 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift @@ -19,13 +19,14 @@ import SwiftUI import Combine -public enum MarkDoneState: Equatable { - case notDone - case loading - case done -} - public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableObject { + + public enum MarkDoneState: Equatable { + case notDone + case loading + case done + } + /// This is the view identity that might change. Don't use this for business logic. public private(set) var id: String = UUID.string public let type: PlannableType @@ -105,6 +106,10 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO self.overrideId = overrideId } + /// Resets the view identity to force SwiftUI to recreate the view. + /// This is necessary when an item is restored after being marked as done to ensure + /// the view is fully re-created. Without this, SwiftUI reuses the old + /// view instance where the swipe gesture is already in the fully swiped state. public func resetViewIdentity() { id = UUID.string } From 9f3fadd29e28140d2cbfa6a5392d65b5eeb4cd0b Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 22 Oct 2025 11:12:02 +0200 Subject: [PATCH 48/49] Refactor timer management and fix API response handling. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract cancelDelayedRemove helper to properly cancel and remove timers - Fix UpdatePlannerOverrideRequest to return APIPlannerOverride instead of APINoContent - Remove debug-only .make() usage from production code in MarkPlannableItemDone - Simplify API request handling to use actual responses - Update test mocks to return proper APIPlannerOverride objects - Add MARK comments for better code organization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Planner/MarkPlannableItemDone.swift | 18 ++-------------- .../Planner/Model/API/APIPlannable.swift | 2 +- .../Todos/ViewModel/TodoListViewModel.swift | 18 +++++++++++----- .../Planner/MarkPlannableItemDoneTests.swift | 7 ++++++- .../Todos/Model/TodoInteractorLiveTests.swift | 21 ++++++++++++++++--- 5 files changed, 40 insertions(+), 26 deletions(-) diff --git a/Core/Core/Features/Planner/MarkPlannableItemDone.swift b/Core/Core/Features/Planner/MarkPlannableItemDone.swift index 9124caf82f..821459089c 100644 --- a/Core/Core/Features/Planner/MarkPlannableItemDone.swift +++ b/Core/Core/Features/Planner/MarkPlannableItemDone.swift @@ -76,9 +76,7 @@ public struct MarkPlannableItemDone: UseCase { marked_complete: done ) ) - environment.api.makeRequest(request) { (response: APIPlannerOverride?, urlResponse, error) in - completionHandler(response, urlResponse, error) - } + environment.api.makeRequest(request, callback: completionHandler) } private func updateExistingOverride(overrideId: String, environment: AppEnvironment, completionHandler: @escaping RequestCallback) { @@ -86,18 +84,6 @@ public struct MarkPlannableItemDone: UseCase { overrideId: overrideId, body: .init(marked_complete: done) ) - let updatedOverride = APIPlannerOverride.make( - id: ID(overrideId), - plannable_type: plannableType, - plannable_id: ID(plannableId), - marked_complete: done - ) - environment.api.makeRequest(request) { (_: APINoContent?, urlResponse, error) in - if error == nil { - completionHandler(updatedOverride, urlResponse, error) - } else { - completionHandler(nil, urlResponse, error) - } - } + environment.api.makeRequest(request, callback: completionHandler) } } diff --git a/Core/Core/Features/Planner/Model/API/APIPlannable.swift b/Core/Core/Features/Planner/Model/API/APIPlannable.swift index 3f3014c0be..72d5078413 100644 --- a/Core/Core/Features/Planner/Model/API/APIPlannable.swift +++ b/Core/Core/Features/Planner/Model/API/APIPlannable.swift @@ -267,7 +267,7 @@ public struct GetPlannablesRequest: APIRequestable { // https://canvas.instructure.com/doc/api/planner.html#method.planner_overrides.update public struct UpdatePlannerOverrideRequest: APIRequestable { - public typealias Response = APINoContent + public typealias Response = APIPlannerOverride public struct Body: Codable, Equatable { let marked_complete: Bool } diff --git a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift index 0008054b1e..a70354e5ea 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift @@ -22,7 +22,7 @@ import CombineExt import CombineSchedulers import SwiftUI -public class TodoListViewModel: ObservableObject { +class TodoListViewModel: ObservableObject { @Published var items: [TodoGroupViewModel] = [] @Published var state: InstUI.ScreenState = .loading let screenConfig = InstUI.BaseScreenConfig( @@ -59,7 +59,9 @@ public class TodoListViewModel: ObservableObject { refresh(completion: { }, ignoreCache: false) } - public func refresh(completion: @escaping () -> Void, ignoreCache: Bool) { + // MARK: - User Actions + + func refresh(completion: @escaping () -> Void, ignoreCache: Bool) { interactor.refresh(ignoreCache: ignoreCache) .sinkFailureOrValue { [weak self] _ in self?.state = .error @@ -142,6 +144,8 @@ public class TodoListViewModel: ObservableObject { .store(in: &subscriptions) } + // MARK: - Private Methods + private func restoreItem(withId itemId: String) { guard let itemToRestore = interactor.todoGroups.value .flatMap({ $0.items }) @@ -176,7 +180,7 @@ public class TodoListViewModel: ObservableObject { } private func performMarkAsDone(_ item: TodoItemViewModel) { - markDoneTimers[item.plannableId]?.cancel() + cancelDelayedRemove(for: item) item.markDoneState = .loading interactor.markItemAsDone(item, done: true) @@ -192,8 +196,7 @@ public class TodoListViewModel: ObservableObject { } private func performMarkAsUndone(_ item: TodoItemViewModel) { - markDoneTimers[item.plannableId]?.cancel() - markDoneTimers.removeValue(forKey: item.plannableId) + cancelDelayedRemove(for: item) item.markDoneState = .loading interactor.markItemAsDone(item, done: false) @@ -212,6 +215,11 @@ public class TodoListViewModel: ObservableObject { .store(in: &subscriptions) } + private func cancelDelayedRemove(for item: TodoItemViewModel) { + markDoneTimers[item.plannableId]?.cancel() + markDoneTimers.removeValue(forKey: item.plannableId) + } + private func handleMarkAsDoneSuccess(_ item: TodoItemViewModel) { item.markDoneState = .done diff --git a/Core/CoreTests/Features/Planner/MarkPlannableItemDoneTests.swift b/Core/CoreTests/Features/Planner/MarkPlannableItemDoneTests.swift index 0913e9c07b..f650d1162e 100644 --- a/Core/CoreTests/Features/Planner/MarkPlannableItemDoneTests.swift +++ b/Core/CoreTests/Features/Planner/MarkPlannableItemDoneTests.swift @@ -90,7 +90,12 @@ class MarkPlannableItemDoneTests: CoreTestCase { overrideId: "override-123", body: .init(marked_complete: false) ) - api.mock(updateRequest, value: APINoContent()) + api.mock(updateRequest, value: APIPlannerOverride.make( + id: "override-123", + plannable_type: "assignment", + plannable_id: ID("123"), + marked_complete: false + )) let expectation = XCTestExpectation(description: "request completes") useCase.makeRequest(environment: environment) { response, _, error in diff --git a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift index 3ecded8552..52748a299e 100644 --- a/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift +++ b/Core/CoreTests/Features/Todos/Model/TodoInteractorLiveTests.swift @@ -276,7 +276,12 @@ class TodoInteractorLiveTests: CoreTestCase { overrideId: "override-123", body: .init(marked_complete: false) ) - api.mock(updateRequest, value: APINoContent()) + api.mock(updateRequest, value: APIPlannerOverride.make( + id: "override-123", + plannable_type: "assignment", + plannable_id: ID("123"), + marked_complete: false + )) // When XCTAssertFinish(testee.markItemAsDone(item, done: false)) @@ -393,7 +398,12 @@ class TodoInteractorLiveTests: CoreTestCase { overrideId: "override-123", body: .init(marked_complete: false) ) - api.mock(updateRequest, value: APINoContent()) + api.mock(updateRequest, value: APIPlannerOverride.make( + id: "override-123", + plannable_type: "assignment", + plannable_id: ID("123"), + marked_complete: false + )) // When XCTAssertFinish(testee.markItemAsDone(item, done: false)) @@ -447,7 +457,12 @@ class TodoInteractorLiveTests: CoreTestCase { overrideId: "override-123", body: .init(marked_complete: false) ) - api.mock(updateRequest, value: APINoContent()) + api.mock(updateRequest, value: APIPlannerOverride.make( + id: "override-123", + plannable_type: "assignment", + plannable_id: ID("123"), + marked_complete: false + )) // When XCTAssertFinish(testee.markItemAsDone(item, done: false)) From f083389a51a3554365e30bd1d122de07cad14554 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 22 Oct 2025 12:02:22 +0200 Subject: [PATCH 49/49] Remove unnecessary HStack and public modifiers. Add preview for swipe to remove. --- .../CommonUI/InstUI/Utils/SwipeToRemove.swift | 33 ++++++++++++ .../Features/Todos/Model/TodoInteractor.swift | 21 ++++---- .../Todos/View/TodoListItemCell.swift | 54 +++++++++---------- .../Features/Todos/View/TodoListScreen.swift | 6 +-- .../Todos/ViewModel/TodoGroupViewModel.swift | 22 ++++---- 5 files changed, 83 insertions(+), 53 deletions(-) diff --git a/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift b/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift index 979ce50350..747d8aec0f 100644 --- a/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift +++ b/Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift @@ -192,3 +192,36 @@ private struct SwipeToRemoveModifier: ViewModifier { } } } + +#if DEBUG + +#Preview { + ScrollView { + VStack(spacing: 0) { + ForEach(0..<5) { index in + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: "Todo Item \(index + 1)") + .font(.headline) + Text(verbatim: "Swipe left to mark as done") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.backgroundLightest) + .swipeToRemove( + backgroundColor: .backgroundSuccess, + onSwipe: {}, + label: { + Image(systemName: "checkmark.circle.fill") + .font(.title) + .foregroundStyle(.textLightest) + .paddingStyle(.horizontal, .standard) + } + ) + } + } + } +} + +#endif diff --git a/Core/Core/Features/Todos/Model/TodoInteractor.swift b/Core/Core/Features/Todos/Model/TodoInteractor.swift index 0deb924f8c..5352de9290 100644 --- a/Core/Core/Features/Todos/Model/TodoInteractor.swift +++ b/Core/Core/Features/Todos/Model/TodoInteractor.swift @@ -19,24 +19,23 @@ import Foundation import Combine -public protocol TodoInteractor { +protocol TodoInteractor { var todoGroups: CurrentValueSubject<[TodoGroupViewModel], Never> { get } func refresh(ignoreCache: Bool) -> AnyPublisher func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher } -public final class TodoInteractorLive: TodoInteractor { - public var todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>([]) +final class TodoInteractorLive: TodoInteractor { + var todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>([]) private let env: AppEnvironment - private var subscriptions = Set() init(env: AppEnvironment) { self.env = env } - public func refresh(ignoreCache: Bool) -> AnyPublisher { + func refresh(ignoreCache: Bool) -> AnyPublisher { let startDate = Clock.now.addDays(-28) let endDate = Clock.now.addDays(28) let currentUserID = env.currentSession?.userID @@ -77,7 +76,7 @@ public final class TodoInteractorLive: TodoInteractor { .eraseToAnyPublisher() } - public func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher { + func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher { let useCase = MarkPlannableItemDone( plannableId: item.plannableId, plannableType: item.plannableType, @@ -119,10 +118,10 @@ public final class TodoInteractorLive: TodoInteractor { #if DEBUG -public final class TodoInteractorPreview: TodoInteractor { - public let todoGroups: CurrentValueSubject<[TodoGroupViewModel], Never> +final class TodoInteractorPreview: TodoInteractor { + let todoGroups: CurrentValueSubject<[TodoGroupViewModel], Never> - public init(todoGroups: [TodoGroupViewModel]? = nil) { + init(todoGroups: [TodoGroupViewModel]? = nil) { if let todoGroups { self.todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>(todoGroups) return @@ -147,11 +146,11 @@ public final class TodoInteractorPreview: TodoInteractor { self.todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>([todayGroup, tomorrowGroup]) } - public func refresh(ignoreCache: Bool) -> AnyPublisher { + func refresh(ignoreCache: Bool) -> AnyPublisher { Publishers.typedJust(()) } - public func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher { + func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher { Publishers.typedJust(()) } } diff --git a/Core/Core/Features/Todos/View/TodoListItemCell.swift b/Core/Core/Features/Todos/View/TodoListItemCell.swift index 26929db154..61df77807c 100644 --- a/Core/Core/Features/Todos/View/TodoListItemCell.swift +++ b/Core/Core/Features/Todos/View/TodoListItemCell.swift @@ -29,38 +29,36 @@ struct TodoListItemCell: View { let isSwiping: Binding? var body: some View { - VStack(spacing: 0) { - HStack(spacing: 0) { - TodoItemContentView(item: item, isCompactLayout: false) + HStack(spacing: 0) { + TodoItemContentView(item: item, isCompactLayout: false) - checkboxButton - .paddingStyle(.leading, .cellAccessoryPadding) - .accessibilityHidden(true) - } - .padding(.vertical, 8) - .paddingStyle(.trailing, .standard) - .background(.backgroundLightest) - .contentShape(Rectangle()) - .onTapGesture { - guard isSwiping?.wrappedValue != true else { return } - onTap(item, viewController) - } - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .accessibilityActions { - if let label = item.markAsDoneAccessibilityLabel { - Button(label) { - onMarkAsDone(item) - } + checkboxButton + .paddingStyle(.leading, .cellAccessoryPadding) + .accessibilityHidden(true) + } + .padding(.vertical, 8) + .paddingStyle(.trailing, .standard) + .background(.backgroundLightest) + .contentShape(Rectangle()) + .onTapGesture { + guard isSwiping?.wrappedValue != true else { return } + onTap(item, viewController) + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityActions { + if let label = item.markAsDoneAccessibilityLabel { + Button(label) { + onMarkAsDone(item) } } - .swipeToRemove( - backgroundColor: .backgroundSuccess, - isSwiping: isSwiping, - onSwipe: { onSwipeMarkAsDone(item) }, - label: { swipeActionView } - ) } + .swipeToRemove( + backgroundColor: .backgroundSuccess, + isSwiping: isSwiping, + onSwipe: { onSwipeMarkAsDone(item) }, + label: { swipeActionView } + ) } private var swipeActionView: some View { diff --git a/Core/Core/Features/Todos/View/TodoListScreen.swift b/Core/Core/Features/Todos/View/TodoListScreen.swift index 880ac5a5f3..abb6db6765 100644 --- a/Core/Core/Features/Todos/View/TodoListScreen.swift +++ b/Core/Core/Features/Todos/View/TodoListScreen.swift @@ -18,7 +18,7 @@ import SwiftUI -public struct TodoListScreen: View { +struct TodoListScreen: View { @Environment(\.viewController) private var viewController @Environment(\.dynamicTypeSize) private var dynamicTypeSize @ObservedObject var viewModel: TodoListViewModel @@ -26,11 +26,11 @@ public struct TodoListScreen: View { @ScaledMetric private var uiScale: CGFloat = 1 @State private var isCellSwiping = false - public init(viewModel: TodoListViewModel) { + init(viewModel: TodoListViewModel) { self.viewModel = viewModel } - public var body: some View { + var body: some View { InstUI.BaseScreen( state: viewModel.state, config: viewModel.screenConfig, diff --git a/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift b/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift index ee5aaf75cd..ff040dbc39 100644 --- a/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift +++ b/Core/Core/Features/Todos/ViewModel/TodoGroupViewModel.swift @@ -18,17 +18,17 @@ import Foundation -public struct TodoGroupViewModel: Identifiable, Equatable, Comparable { - public let id: String - public let date: Date - public let items: [TodoItemViewModel] - public let weekdayAbbreviation: String - public let dayNumber: String - public let isToday: Bool - public let displayDate: String - public let accessibilityLabel: String +struct TodoGroupViewModel: Identifiable, Equatable, Comparable { + let id: String + let date: Date + let items: [TodoItemViewModel] + let weekdayAbbreviation: String + let dayNumber: String + let isToday: Bool + let displayDate: String + let accessibilityLabel: String - public init(date: Date, items: [TodoItemViewModel]) { + init(date: Date, items: [TodoItemViewModel]) { self.id = date.isoString() self.date = date self.items = items @@ -45,7 +45,7 @@ public struct TodoGroupViewModel: Identifiable, Equatable, Comparable { // MARK: - Comparable - public static func < (lhs: TodoGroupViewModel, rhs: TodoGroupViewModel) -> Bool { + static func < (lhs: TodoGroupViewModel, rhs: TodoGroupViewModel) -> Bool { lhs.date < rhs.date } }