Skip to content
Merged
Show file tree
Hide file tree
Changes from 47 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
70bd109
Make some properties public on SessionDefaults to allow external exte…
vargaat Jan 7, 2026
37a43e7
Add skeleton architecture.
vargaat Jan 13, 2026
bce4cae
Style updates.
vargaat Jan 13, 2026
901b44e
Add state to widgets. Add example transitions. Fix animations and pad…
vargaat Jan 13, 2026
281a8b7
Animation fixes. Renaming.
vargaat Jan 14, 2026
22442de
Layout updates
vargaat Jan 15, 2026
8e0097e
Add tests.
vargaat Jan 15, 2026
325c7f2
Merge branch 'master' into feature/MBL-19528-dashboard-widget-foundat…
vargaat Jan 15, 2026
f7da8c0
Add empty protocol file to code coverage exclusion list.
vargaat Jan 15, 2026
fb666ce
Merge branch 'master' into feature/MBL-19528-dashboard-widget-foundat…
vargaat Jan 20, 2026
e9ce333
Add carousel widget and course invitations skeleton.
vargaat Jan 21, 2026
563a72a
Merge branch 'master' into feature/MBL-19532-Course-Invitations
vargaat Jan 23, 2026
0031e87
Add thread safe course fetch interactor.
vargaat Jan 23, 2026
e82ebe2
Update profile button color to use textDarkest
github-actions[bot] Jan 23, 2026
1f9ebd2
Merge branch 'master' into feature/MBL-19528-dashboard-widget-foundat…
vargaat Jan 26, 2026
f2941ce
Rename files.
vargaat Jan 27, 2026
1737e5c
Implement code review suggestions.
vargaat Jan 27, 2026
34dbb60
- Remove example horizontal widget.
vargaat Jan 27, 2026
2c9a266
Merge branch 'feature/MBL-19528-dashboard-widget-foundations' into fe…
vargaat Jan 27, 2026
cebd361
Add previews and animations.
vargaat Jan 29, 2026
703d4ec
Improve error and success feedback on ui.
vargaat Jan 29, 2026
cf63c3c
- Fix menu button color on iOS 26.
vargaat Jan 29, 2026
89630b7
Fix widget title not getting resized on font size change.
vargaat Jan 29, 2026
c15fc51
Fix swiftlint.
vargaat Jan 29, 2026
ffcc50a
Merge branch 'feature/MBL-19528-dashboard-widget-foundations' into fe…
vargaat Jan 29, 2026
bf318cb
Use shared animation.
vargaat Jan 29, 2026
d3651da
Merge branch 'master' into feature/MBL-19532-Course-Invitations
vargaat Jan 29, 2026
9910d83
Use pill buttons.
vargaat Jan 29, 2026
f7ec2e7
Remove offline warnings from view model.
vargaat Jan 29, 2026
b32afc9
Improve accessibility.
vargaat Jan 29, 2026
df94706
Merge branch 'master' into feature/MBL-19532-Course-Invitations
vargaat Jan 30, 2026
9578e46
Optimize example widget refresh animation.
vargaat Jan 30, 2026
f925b17
Update interactor to support ignore cache.
vargaat Jan 30, 2026
a39dc9e
Add enrollment fetch to courses. Fix deadlock in interactor.
vargaat Jan 30, 2026
23355ce
Sort course invitations by invitation date.
vargaat Feb 2, 2026
69e9b7b
Move CoreData fetch to a background queue.
vargaat Feb 2, 2026
9f7966c
Add more unit tests.
vargaat Feb 2, 2026
b873b6b
Fix swiftui deprecations.
vargaat Feb 3, 2026
fd4bed9
Add page indicator.
vargaat Feb 3, 2026
04e5519
Move helper to a better place.
vargaat Feb 3, 2026
fa3dd52
Improve preview.
vargaat Feb 3, 2026
d4e5800
Work around animation glitches.
vargaat Feb 4, 2026
25f566b
Properly update database after invitation status changes.
vargaat Feb 4, 2026
32c0aa3
Fix retain cycle.
vargaat Feb 4, 2026
ebea358
Update unit test.
vargaat Feb 4, 2026
0aa5a52
Merge branch 'master' into feature/MBL-19532-Course-Invitations
vargaat Feb 4, 2026
727064b
Improve empty section name handling.
vargaat Feb 4, 2026
38d01a0
Remove auto widget refresh.
vargaat Feb 5, 2026
7c43add
Merge branch 'master' into feature/MBL-19532-Course-Invitations
vargaat Feb 5, 2026
6332477
Implement review findings. Revert accept and decline button order.
vargaat Feb 6, 2026
09bb788
Remove extra new line.
vargaat Feb 6, 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
2 changes: 1 addition & 1 deletion Core/Core/Common/CommonUI/SwiftUIViews/ErrorAlert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import Combine
import SwiftUI

public struct ErrorAlertViewModel {
public struct ErrorAlertViewModel: Equatable {
public var title: String
public var message: String
public var buttonTitle: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ extension NSPersistentContainer {
context.perform { block(context) }
}

public var backgroundReadContext: NSManagedObjectContext {
cachedBackgroundReadContext ?? {
let context = newBackgroundContext()
context.automaticallyMergesChangesFromParent = true
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
cachedBackgroundReadContext = context
return context
}()
}

// MARK: - Private Methods

private static func destroyAndReCreatePersistentStore(in container: NSPersistentContainer) {
Expand Down Expand Up @@ -141,6 +151,12 @@ extension NSPersistentContainer {
get { objc_getAssociatedObject(self, &writeContextKey) as? NSManagedObjectContext }
set { objc_setAssociatedObject(self, &writeContextKey, newValue, .OBJC_ASSOCIATION_RETAIN) }
}

private var cachedBackgroundReadContext: NSManagedObjectContext? {
get { objc_getAssociatedObject(self, &backgroundReadContextKey) as? NSManagedObjectContext }
set { objc_setAssociatedObject(self, &backgroundReadContextKey, newValue, .OBJC_ASSOCIATION_RETAIN) }
}
}

private var writeContextKey: UInt8 = 0
private var backgroundReadContextKey: UInt8 = 0
27 changes: 15 additions & 12 deletions Core/Core/Features/Courses/Course.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,14 @@ final public class Course: NSManagedObject, WriteableModel {
model.syllabusBody = item.syllabus_body
model.defaultViewRaw = item.default_view?.rawValue
model.enrollments?.forEach { enrollment in
// We only want to delete enrollments created from
// the minimal enrollments attached to an APICourse
// We only want to delete enrollments containing grades. These are created from
// the minimal enrollments attached to an APICourse and recognizable by not having an enrollment id.
if enrollment.id == nil {
context.delete(enrollment)
}
}
model.enrollments = nil

if let apiGradingPeriods = item.grading_periods {
let gradingPeriods: [GradingPeriod] = apiGradingPeriods.map { apiGradingPeriod in
let gp: GradingPeriod = GradingPeriod.save(apiGradingPeriod, courseID: model.id, in: context)
Expand All @@ -136,16 +137,18 @@ final public class Course: NSManagedObject, WriteableModel {
model.termName = item.term?.name
model.accessRestrictedByDate = item.access_restricted_by_date ?? false

if let apiEnrollments = item.enrollments {
let enrollmentModels: [Enrollment] = apiEnrollments.map { apiItem in
/// This enrollment contains the grade fields necessary to calculate grades on the dashboard.
/// This is a special enrollment that has no courseID nor enrollmentID and contains no Grade objects.
let e: Enrollment = context.insert()
e.update(fromApiModel: apiItem, course: model, in: context)
return e
}
model.enrollments = Set(enrollmentModels)
}
let gradeEnrollments: [Enrollment] = (item.enrollments ?? []).map { apiItem in
/// This enrollment contains the grade fields necessary to calculate grades on the dashboard.
/// This is a special enrollment that has no courseID nor enrollmentID and contains no Grade objects.
let e: Enrollment = context.insert()
e.update(fromApiModel: apiItem, course: model, in: context)
return e
}
// Also link fully qualified enrollment objects of this course already existing in the DB.
let fetchedDBEnrollments: [Enrollment] = context.fetch(
scope: .where(#keyPath(Enrollment.canvasContextID), equals: model.canvasContextID)
).filter { $0.id != nil }
model.enrollments = Set(gradeEnrollments + fetchedDBEnrollments)

if let contextColor: ContextColor = context.fetch(scope: .where(#keyPath(ContextColor.canvasContextID), equals: model.canvasContextID)).first {
model.contextColor = contextColor
Expand Down
10 changes: 9 additions & 1 deletion Core/Core/Features/Enrollments/APIEnrollment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public struct APIEnrollment: Codable, Equatable {
let associated_user_id: ID?
let role: String
let role_id: String
// let created_at: Date
let created_at: Date?
// let updated_at: Date
let start_at: Date?
let end_at: Date?
Expand Down Expand Up @@ -105,6 +105,7 @@ extension APIEnrollment {
associated_user_id: String? = nil,
role: String = "StudentEnrollment",
role_id: String = "3",
created_at: Date? = nil,
start_at: Date? = nil,
end_at: Date? = nil,
last_activity_at: Date? = nil,
Expand Down Expand Up @@ -134,6 +135,7 @@ extension APIEnrollment {
associated_user_id: ID(associated_user_id),
role: role,
role_id: role_id,
created_at: created_at,
start_at: start_at,
end_at: end_at,
last_activity_at: last_activity_at,
Expand Down Expand Up @@ -275,6 +277,12 @@ public struct HandleCourseInvitationRequest: APIRequestable {
let enrollmentID: String
let isAccepted: Bool

public init(courseID: String, enrollmentID: String, isAccepted: Bool) {
self.courseID = courseID
self.enrollmentID = enrollmentID
self.isAccepted = isAccepted
}

public var method: APIMethod { .post }
public var path: String { "courses/\(courseID)/enrollments/\(enrollmentID)/\(isAccepted ? "accept" : "reject")" }
}
130 changes: 130 additions & 0 deletions Core/Core/Features/Enrollments/AcceptCourseInvitation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//
// This file is part of Canvas.
// Copyright (C) 2026-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 <https://www.gnu.org/licenses/>.
//

import Combine
import CoreData
import Foundation

public struct InvitationAcceptResponse: Codable {
let success: Bool
let course: APICourse?
let enrollments: [APIEnrollment]?
}

public class AcceptCourseInvitation: UseCase {
public typealias Model = Enrollment
public typealias Response = InvitationAcceptResponse

public var scope: Scope {
.where(#keyPath(Enrollment.id), equals: enrollmentID)
}
public let cacheKey: String? = nil
public let ttl: TimeInterval = 0

private let courseID: String
private let enrollmentID: String
private var subscriptions = Set<AnyCancellable>()

public init(courseID: String, enrollmentID: String) {
self.courseID = courseID
self.enrollmentID = enrollmentID
}

public func makeRequest(environment: AppEnvironment, completionHandler: @escaping RequestCallback) {
let handleRequest = HandleCourseInvitationRequest(
courseID: courseID,
enrollmentID: enrollmentID,
isAccepted: true
)
let courseID = courseID

environment.api.makeRequest(handleRequest)
.flatMap { handleResponse, handleURLResponse -> AnyPublisher<(InvitationAcceptResponse, URLResponse?), Error> in
guard handleResponse.success else {
return Fail(error: NSError.internalError())
.eraseToAnyPublisher()
}

let getCoursePublisher = environment.api.makeRequest(GetCourseRequest(courseID: courseID))
.map { $0.body }
.catch { _ -> Just<APICourse?> in
return Just(nil)
}
.setFailureType(to: Error.self)

let getEnrollmentsPublisher = environment.api.makeRequest(GetEnrollmentsRequest(context: .course(courseID)))
.map { $0.body }
.catch { _ -> Just<[APIEnrollment]?> in
return Just(nil)
}
.setFailureType(to: Error.self)

return Publishers.Zip(getCoursePublisher, getEnrollmentsPublisher)
.map { courseResponse, enrollmentsResponse in
let response = InvitationAcceptResponse(
success: true,
course: courseResponse,
enrollments: enrollmentsResponse
)
return (response, handleURLResponse)
}
.eraseToAnyPublisher()
}
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion {
completionHandler(nil, nil, error)
}
},
receiveValue: { response, urlResponse in
completionHandler(response, urlResponse, nil)
}
)
.store(in: &subscriptions)
}

public func write(response: InvitationAcceptResponse?, urlResponse: URLResponse?, to context: NSManagedObjectContext) {
guard let response else { return }

if let apiCourse = response.course {
Course.save(apiCourse, in: context)
}

guard let apiEnrollments = response.enrollments else {
return
}

for apiEnrollment in apiEnrollments {
guard let enrollmentId = apiEnrollment.id?.value else { continue }
let enrollment: Enrollment = context.first(
where: #keyPath(Enrollment.id),
equals: enrollmentId
) ?? context.insert()

enrollment.update(
fromApiModel: apiEnrollment,
course: nil,
in: context
)

if enrollmentId == self.enrollmentID {
enrollment.isFromInvitation = false
}
}
}
}
60 changes: 60 additions & 0 deletions Core/Core/Features/Enrollments/DeclineCourseInvitation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// This file is part of Canvas.
// Copyright (C) 2026-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 <https://www.gnu.org/licenses/>.
//

import CoreData
import Foundation

public class DeclineCourseInvitation: UseCase {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting way of using UseCase!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just realized I could have used DeleteUseCase to make this simpler. Next time.

public typealias Model = Enrollment
public typealias Response = HandleCourseInvitationRequest.Response

public var scope: Scope {
.where(#keyPath(Enrollment.id), equals: enrollmentID)
}
public let cacheKey: String? = nil
public let ttl: TimeInterval = 0

private let courseID: String
private let enrollmentID: String

public init(courseID: String, enrollmentID: String) {
self.courseID = courseID
self.enrollmentID = enrollmentID
}

public func makeRequest(environment: AppEnvironment, completionHandler: @escaping RequestCallback) {
let request = HandleCourseInvitationRequest(
courseID: courseID,
enrollmentID: enrollmentID,
isAccepted: false
)

environment.api.makeRequest(request, callback: completionHandler)
}

public func write(response: Response?, urlResponse: URLResponse?, to context: NSManagedObjectContext) {
guard response?.success == true else { return }

if let enrollment: Enrollment = context.first(
where: #keyPath(Enrollment.id),
equals: enrollmentID
) {
context.delete([enrollment])
}
}
}
14 changes: 12 additions & 2 deletions Core/Core/Features/Enrollments/Enrollment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ final public class Enrollment: NSManagedObject {
@NSManaged public var observedUser: User?
@NSManaged public var isFromInvitation: Bool
@NSManaged public var lastActivityAt: Date?
@NSManaged public var createdAt: Date?

@NSManaged public var computedCurrentScoreRaw: NSNumber?
@NSManaged public var computedCurrentGrade: String?
Expand Down Expand Up @@ -192,12 +193,21 @@ extension Enrollment {
userID = item.user_id.value
courseSectionID = item.course_section_id?.value
lastActivityAt = item.last_activity_at
createdAt = item.created_at

if let courseID = item.course_id?.value ?? course?.id {
let courseID = item.course_id?.value ?? course?.id
if let courseID = courseID {
canvasContextID = "course_\(courseID)"
}

self.course = course
// Link enrollment to course, either the provided one or find existing in DB by course ID.
// CoreData automatically maintains the inverse relationship, so setting self.course
// will automatically add this enrollment to the course's enrollments set.
self.course = course ?? {
guard let courseID else { return nil }
let courses: [Course] = client.fetch(scope: .where(#keyPath(Course.id), equals: courseID))
return courses.first
}()

if let apiGrades = item.grades {
let grade = grades.first { $0.gradingPeriodID == gradingPeriodID } ?? client.insert()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="isFromInvitation" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastActivityAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="multipleGradingPeriodsEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="role" optional="YES" attributeType="String"/>
<attribute name="roleID" optional="YES" attributeType="String"/>
Expand Down
Loading