Skip to content
Draft
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
eba67bd
Implement base todo widget
domonkosadam Mar 4, 2026
c6c9768
UI improvements
domonkosadam Mar 4, 2026
fc53a88
UI improvements
domonkosadam Mar 4, 2026
bedc5a2
UI fixes
domonkosadam Mar 4, 2026
4f2af0e
UI improvements
domonkosadam Mar 4, 2026
771a8e4
Implement lazy load
domonkosadam Mar 4, 2026
7b8552c
UI improvements
domonkosadam Mar 4, 2026
f9ba30d
UI fixes
domonkosadam Mar 4, 2026
888987f
Bug fixes
domonkosadam Mar 5, 2026
61663b5
Add snackbars
domonkosadam Mar 5, 2026
8d4255d
UI fixes
domonkosadam Mar 5, 2026
3285d24
Code fixes
domonkosadam Mar 5, 2026
9783305
Code improvements
domonkosadam Mar 5, 2026
b445ec7
Implement tests
domonkosadam Mar 5, 2026
2e16d8a
a11y improvements
domonkosadam Mar 9, 2026
dd15748
Merge branch 'master' into MBL-19538-ToDo-dashboard-widget
domonkosadam Mar 9, 2026
41f2881
Fix tests
domonkosadam Mar 10, 2026
a37aaf2
Implement refresh logic
domonkosadam Mar 11, 2026
526dd79
Fix snackbar issue
domonkosadam Mar 11, 2026
e81ef23
Fix navigation
domonkosadam Mar 11, 2026
c36ee14
Fix tests
domonkosadam Mar 11, 2026
22d3aff
Fix test
domonkosadam Mar 11, 2026
4fd14e7
Merge branch 'master' into MBL-19538-ToDo-dashboard-widget
domonkosadam Mar 12, 2026
700ba46
Fix merge issues
domonkosadam Mar 12, 2026
194b5ca
Update TodoInteractorLiveTests.swift
domonkosadam Mar 12, 2026
45de378
Update LearnerDashboardInteractorTests.swift
domonkosadam Mar 12, 2026
4735177
Review findings
rh12 Mar 13, 2026
d171786
Merge branch 'master' into MBL-19538-ToDo-dashboard-widget
rh12 Mar 13, 2026
ee8c8d5
Resolve conflict
rh12 Mar 13, 2026
3028015
Review findings
rh12 Mar 13, 2026
eac57e5
Update view
rh12 Mar 14, 2026
7674c80
Update week view & pager
rh12 Mar 16, 2026
de26b9b
Extract list view
rh12 Mar 18, 2026
2aa4b2f
Fix unit test
rh12 Mar 19, 2026
cef3dc1
Merge branch 'master' into MBL-19538-ToDo-dashboard-widget
rh12 Mar 19, 2026
8e610cd
Resolve conflicts
rh12 Mar 19, 2026
349758a
Update colors to use dashboard color
rh12 Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 104 additions & 9 deletions Core/Core/Features/Todos/Model/TodoInteractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ public protocol TodoInteractor {
/// - Returns: A publisher that completes when the refresh operation finishes.
func refresh(ignorePlannablesCache: Bool, ignoreCoursesCache: Bool) -> AnyPublisher<Void, Error>

/// Fetches todos for a specific date range without affecting the badge count.
/// Intended for use by the widget to load data lazily per visible week.
///
/// - Parameters:
/// - startDate: The start of the date range to fetch.
/// - endDate: The end of the date range to fetch.
/// - ignorePlannablesCache: If `true`, forces a fetch of plannables from the API.
/// - ignoreCoursesCache: If `true`, forces a fetch of courses from the API.
/// - Returns: A publisher that completes when the refresh operation finishes.
func refresh(
startDate: Date,
endDate: Date,
ignorePlannablesCache: Bool,
ignoreCoursesCache: Bool,
filterOptions: TodoFilterOptions?
) -> AnyPublisher<Void, Error>

/// Checks if the cache has expired for todo data.
///
/// Returns `true` if the cache has expired and the next `refresh()` call will fetch from the API.
Expand Down Expand Up @@ -76,6 +93,8 @@ public final class TodoInteractorLive: TodoInteractor {
private let alwaysExcludeCompleted: Bool
private let scheduler: AnySchedulerOf<DispatchQueue>
private var subscriptions = Set<AnyCancellable>()
private var cachedCourses: [Course]?
private var lastUsedFilterOptions: TodoFilterOptions?

public init(
alwaysExcludeCompleted: Bool,
Expand All @@ -89,12 +108,45 @@ public final class TodoInteractorLive: TodoInteractor {
self.scheduler = scheduler
self.coursesStore = ReactiveStore(useCase: GetCourses(), environment: env)
self.contextColorsStore = ReactiveStore(useCase: GetCustomColors(), environment: env)
setupLocalObservation()
}

// MARK: - Public Methods

public func refresh(ignorePlannablesCache: Bool, ignoreCoursesCache: Bool) -> AnyPublisher<Void, Error> {
refresh(ignorePlannablesCache: ignorePlannablesCache, ignoreCoursesCache: ignoreCoursesCache, retryCount: 0)
refresh(
plannablesStore: makePlannablesStore(),
ignorePlannablesCache: ignorePlannablesCache,
ignoreCoursesCache: ignoreCoursesCache,
skipBadgeUpdate: false,
retryCount: 0
)
}

public func refresh(
startDate: Date,
endDate: Date,
ignorePlannablesCache: Bool,
ignoreCoursesCache: Bool,
filterOptions: TodoFilterOptions? = nil
) -> AnyPublisher<Void, Error> {
let useCase = GetPlannables(
startDate: startDate,
endDate: endDate,
contextCodes: nil,
allowEmptyContextCodesFetch: true,
useCaseID: nil
)
let store = ReactiveStore(useCase: useCase, environment: env)
let widgetFilterOptions = filterOptions ?? sessionDefaults.todoFilterOptions ?? .default
return refresh(
plannablesStore: store,
ignorePlannablesCache: ignorePlannablesCache,
ignoreCoursesCache: ignoreCoursesCache,
skipBadgeUpdate: true,
filterOptions: widgetFilterOptions,
retryCount: 0
)
}

public func isCacheExpired() -> AnyPublisher<Bool, Never> {
Expand Down Expand Up @@ -135,18 +187,49 @@ public final class TodoInteractorLive: TodoInteractor {

// MARK: - Private Methods

private func refresh(ignorePlannablesCache: Bool, ignoreCoursesCache: Bool, retryCount: Int) -> AnyPublisher<Void, Error> {
let plannableStore = makePlannablesStore()
private func setupLocalObservation() {
let localUseCase = LocalUseCase<Plannable>(scope: GetPlannables.makeTodoFetchUseCase().scope)
ReactiveStore(useCase: localUseCase, environment: env)
.getEntitiesFromDatabase(keepObservingDatabaseChanges: true)
.dropFirst()
.debounce(for: .milliseconds(100), scheduler: scheduler)
.sink { _ in
} receiveValue: { [weak self] plannables in
guard let self,
let courses = self.cachedCourses,
let filterOptions = self.lastUsedFilterOptions else { return }
try? filterAndGroupTodos(
plannables: plannables,
courses: courses,
skipBadgeUpdate: self.alwaysExcludeCompleted,
filterOptions: filterOptions
)
}
.store(in: &subscriptions)
}

private func refresh(
plannablesStore: ReactiveStore<GetPlannables>,
ignorePlannablesCache: Bool,
ignoreCoursesCache: Bool,
skipBadgeUpdate: Bool,
filterOptions: TodoFilterOptions? = nil,
retryCount: Int
) -> AnyPublisher<Void, Error> {
return Publishers.Zip3(
plannableStore.getEntities(ignoreCache: ignorePlannablesCache, loadAllPages: true),
plannablesStore.getEntities(ignoreCache: ignorePlannablesCache, loadAllPages: true),
coursesStore.getEntities(ignoreCache: ignoreCoursesCache),
contextColorsStore.getEntities(ignoreCache: ignoreCoursesCache)
)
.tryMap { [weak self] plannables, courses, _ in
guard let self else { return }
try self.filterAndGroupTodos(plannables: plannables, courses: courses)
self.logFilterAnalytics()
try filterAndGroupTodos(
plannables: plannables,
courses: courses,
skipBadgeUpdate: skipBadgeUpdate,
filterOptions: filterOptions
)
logFilterAnalytics()
}
.catch { [weak self] error -> AnyPublisher<Void, Error> in
let shouldRetry = error as? TodoInteractorError == .deletedCoursesDetected ||
Expand All @@ -162,8 +245,11 @@ public final class TodoInteractorLive: TodoInteractor {
.delay(for: .seconds(0.5), scheduler: scheduler)
.flatMap { [weak self] _ in
self?.refresh(
plannablesStore: plannablesStore,
ignorePlannablesCache: ignorePlannablesCache,
ignoreCoursesCache: true,
skipBadgeUpdate: skipBadgeUpdate,
filterOptions: filterOptions,
retryCount: retryCount + 1
) ?? Publishers.noInstanceFailure()
}
Expand All @@ -176,8 +262,15 @@ public final class TodoInteractorLive: TodoInteractor {
ReactiveStore(useCase: GetPlannables.makeTodoFetchUseCase(), environment: env)
}

private func filterAndGroupTodos(plannables: [Plannable], courses: [Course]) throws {
let filterOptions = sessionDefaults.todoFilterOptions ?? TodoFilterOptions.default
private func filterAndGroupTodos(
plannables: [Plannable],
courses: [Course],
skipBadgeUpdate: Bool,
filterOptions passedFilterOptions: TodoFilterOptions? = nil
) throws {
let filterOptions = passedFilterOptions ?? sessionDefaults.todoFilterOptions ?? TodoFilterOptions.default
cachedCourses = courses
lastUsedFilterOptions = filterOptions

let hasDeletedCourses = courses.contains { $0.isDeleted }
if hasDeletedCourses {
Expand Down Expand Up @@ -205,7 +298,9 @@ public final class TodoInteractorLive: TodoInteractor {
}

let notDoneTodos = todos.filter { $0.markAsDoneState == .notDone }
TabBarBadgeCounts.todoListCount = UInt(notDoneTodos.count)
if !skipBadgeUpdate {
TabBarBadgeCounts.todoListCount = UInt(notDoneTodos.count)
}

let groupedTodos = (alwaysExcludeCompleted ? notDoneTodos : todos).groupByDay()
todoGroups.value = groupedTodos
Expand Down
23 changes: 17 additions & 6 deletions Core/Core/Features/Todos/Model/TodoInteractorPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ import Foundation

#if DEBUG

final class TodoInteractorPreview: TodoInteractor {
let todoGroups: CurrentValueSubject<[TodoGroupViewModel], Never>
public final class TodoInteractorPreview: TodoInteractor {
public let todoGroups: CurrentValueSubject<[TodoGroupViewModel], Never>

init(todoGroups: [TodoGroupViewModel]? = nil) {
public init(todoGroups: [TodoGroupViewModel]? = nil) {
if let todoGroups {
self.todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>(todoGroups)
return
Expand All @@ -49,16 +49,27 @@ final class TodoInteractorPreview: TodoInteractor {
self.todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>([todayGroup, tomorrowGroup])
}

func refresh(ignorePlannablesCache: Bool, ignoreCoursesCache: Bool) -> AnyPublisher<Void, Error> {
public func refresh(ignorePlannablesCache: Bool, ignoreCoursesCache: Bool) -> AnyPublisher<Void, Error> {
todoGroups.send(todoGroups.value)
return Publishers.typedJust()
}

func isCacheExpired() -> AnyPublisher<Bool, Never> {
public func refresh(
startDate: Date,
endDate: Date,
ignorePlannablesCache: Bool,
ignoreCoursesCache: Bool,
filterOptions: TodoFilterOptions?
) -> AnyPublisher<Void, Error> {
todoGroups.send(todoGroups.value)
return Publishers.typedJust()
}

public func isCacheExpired() -> AnyPublisher<Bool, Never> {
Just(false).eraseToAnyPublisher()
}

func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher<String, Error> {
public func markItemAsDone(_ item: TodoItemViewModel, done: Bool) -> AnyPublisher<String, Error> {
Publishers.typedJust("preview-override-id")
}
}
Expand Down
6 changes: 3 additions & 3 deletions Core/Core/Features/Todos/View/TodoListItemCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import SwiftUI

struct TodoListItemCell: View {
public struct TodoListItemCell: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.viewController) private var viewController

Expand All @@ -31,7 +31,7 @@ struct TodoListItemCell: View {

private static let hapticGenerator = UIImpactFeedbackGenerator(style: .light)

init(
public init(
item: TodoItemViewModel,
onTap: @escaping (TodoItemViewModel, WeakViewController) -> Void,
onMarkAsDone: @escaping (TodoItemViewModel) -> Void,
Expand All @@ -47,7 +47,7 @@ struct TodoListItemCell: View {
self._isSwiping = isSwiping
}

var body: some View {
public var body: some View {
HStack(spacing: 0) {
TodoItemContentView(item: item, isCompactLayout: false)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,72 @@ class TodoInteractorLiveTests: CoreTestCase {
XCTAssertEqual(TabBarBadgeCounts.todoListCount, 3)
}

// MARK: - Local Observation Tests

func test_localObservation_updatesWhenNewPlannableWithTodoUseCaseIsInserted() {
// GIVEN
let courses = [makeCourse(id: "1", name: "Course 1")]

mockCourses(courses)
mockPlannables([makePlannable(courseId: "1", plannableId: "first-1", type: "assignment", title: "First Item")])
XCTAssertFinish(testee.refresh(ignorePlannablesCache: false, ignoreCoursesCache: false), timeout: 5)

XCTAssertFirstValue(testee.todoGroups, timeout: 2) { groups in
XCTAssertEqual(groups.flatMap { $0.items }.count, 1)
}

// WHEN - simulate a new todo being inserted with .todo use case ID (as GetPlannables.write() does after API refresh)
let updateExpectation = expectation(description: "todoGroups updated after new item inserted")
var subscription: AnyCancellable?
subscription = testee.todoGroups
.dropFirst()
.first()
.sink { groups in
let allItems = groups.flatMap { $0.items }
XCTAssertEqual(allItems.count, 2)
XCTAssertTrue(allItems.contains(where: { $0.plannableId == "new-1" }))
updateExpectation.fulfill()
subscription?.cancel()
}

Plannable.save(
APIPlannable.make(
course_id: ID("1"),
plannable_id: ID("new-1"),
plannable_type: "assignment",
plannable: .make(title: "New Item"),
plannable_date: Self.mockDate.addDays(1)
),
userId: nil,
useCase: .todo,
in: databaseClient
)

wait(for: [updateExpectation], timeout: 2)
}

func test_localObservation_doesNotUpdateTodosBeforeInitialRefresh() {
// GIVEN - interactor created, refresh() not yet called (cachedCourses is nil)
// WHEN - a plannable is inserted directly into CoreData
Plannable.save(
APIPlannable.make(
course_id: ID("1"),
plannable_id: ID("early-1"),
plannable_type: "assignment",
plannable: .make(title: "Early Item"),
plannable_date: Self.mockDate.addDays(1)
),
userId: nil,
useCase: .todo,
in: databaseClient
)

// THEN - todoGroups stays empty because cachedCourses is nil and the observation is skipped
XCTAssertFirstValue(testee.todoGroups, timeout: 1) { groups in
XCTAssertTrue(groups.isEmpty)
}
}

// MARK: - Helpers

private func mockCourses(_ courses: [APICourse]) {
Expand Down
29 changes: 29 additions & 0 deletions Core/CoreTests/Features/Todos/Model/TodoInteractorMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

@testable import Core
import Combine
import Foundation

final class TodoInteractorMock: TodoInteractor {
var todoGroups = CurrentValueSubject<[TodoGroupViewModel], Never>([])
Expand All @@ -37,6 +38,13 @@ final class TodoInteractorMock: TodoInteractor {
var lastMarkAsDoneDone: Bool?
var markItemAsDoneResult: Result<String, Error> = .success("mock-override-id")

var rangedRefreshCalled = false
var rangedRefreshCallCount = 0
var lastRangedRefreshStartDate: Date?
var lastRangedRefreshEndDate: Date?
var lastRangedRefreshFilterOptions: TodoFilterOptions?
var rangedRefreshResult: Result<Void, Error> = .success(())

func refresh(ignorePlannablesCache: Bool, ignoreCoursesCache: Bool) -> AnyPublisher<Void, Error> {
refreshCalled = true
refreshCallCount += 1
Expand All @@ -54,6 +62,27 @@ final class TodoInteractorMock: TodoInteractor {
}
}

func refresh(
startDate: Date,
endDate: Date,
ignorePlannablesCache: Bool,
ignoreCoursesCache: Bool,
filterOptions: TodoFilterOptions?
) -> AnyPublisher<Void, Error> {
rangedRefreshCalled = true
rangedRefreshCallCount += 1
lastRangedRefreshStartDate = startDate
lastRangedRefreshEndDate = endDate
lastRangedRefreshFilterOptions = filterOptions

switch rangedRefreshResult {
case .success:
return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher()
case .failure(let error):
return Fail(error: error).eraseToAnyPublisher()
}
}

func isCacheExpired() -> AnyPublisher<Bool, Never> {
isCacheExpiredCalled = true
isCacheExpiredCallCount += 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@ enum DashboardWidgetIdentifier: String, Codable, CaseIterable {

case weeklySummary
case coursesAndGroups
case toDo

func title(username: String) -> String {
switch self {
case .conferences: return String(localized: "Conferences", bundle: .student)
case .helloWidget: return String(localized: "Hello \(username)", bundle: .student)
case .weeklySummary: return String(localized: "Weekly Summary", bundle: .student)
case .coursesAndGroups: return String(localized: "Courses & Groups", bundle: .student)
case .offlineSyncProgress, .fileUploadProgress, .globalAnnouncements, .courseInvitations, .weeklySummary:
case .toDo: return String(localized: "Daily To-do", bundle: .student)
case .offlineSyncProgress, .fileUploadProgress, .globalAnnouncements, .courseInvitations:
assertionFailure("\(self) widget should not appear among Dashboard settings")
return rawValue
}
Expand All @@ -44,7 +47,7 @@ enum DashboardWidgetIdentifier: String, Codable, CaseIterable {
switch self {
case .offlineSyncProgress, .fileUploadProgress, .globalAnnouncements, .courseInvitations:
false
case .conferences, .helloWidget, .coursesAndGroups, .weeklySummary:
case .conferences, .helloWidget, .weeklySummary, .coursesAndGroups, .toDo:
true
}
}
Expand Down
Loading
Loading