Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
086a2be
Introduce temporary feature flag.
vargaat Sep 26, 2025
92a4f83
Remove isEmpty state publishing.
vargaat Sep 26, 2025
292f4f9
Add tab and application badge update logic.
vargaat Sep 26, 2025
5c2ac15
Use +-28 days interval for todo fetching.
vargaat Sep 26, 2025
1feaef4
Use local env variable for fetching.
vargaat Sep 26, 2025
7a4813a
Extract todo cell content view to Core.
vargaat Sep 26, 2025
dbc7b04
Update todo item layout.
vargaat Sep 29, 2025
9d217cc
Use shared todo cells for widget and todo screen.
vargaat Sep 29, 2025
892aaf9
Add sticky day headers.
vargaat Sep 29, 2025
cbbe33b
Update layout.
vargaat Sep 30, 2025
7a17678
Route to calendar when tapping on the day header.
vargaat Sep 30, 2025
7ac197a
Improve a11y.
vargaat Sep 30, 2025
4ee2924
Move entities to the view model level.
vargaat Sep 30, 2025
3a9c5c1
Continue renaming.
vargaat Sep 30, 2025
b8d723f
Move sorting logic to view model entities.
vargaat Sep 30, 2025
74c390b
Update unit tests.
vargaat Sep 30, 2025
6bed69e
Update layout.
vargaat Sep 30, 2025
e45f352
Display only time
vargaat Sep 30, 2025
474f0c4
Update empty state.
vargaat Sep 30, 2025
d63b8c6
Update course name lookup logic.
vargaat Oct 1, 2025
9cb87cf
Merge branch 'master' into feature/MBL-19373-Update-ToDo-Screen
vargaat Oct 1, 2025
465b4b6
Update public previews.
vargaat Oct 1, 2025
7e0273c
Update dividers for better section scrolling experience.
vargaat Oct 2, 2025
da84fa7
Merge branch 'master' into feature/MBL-19373-Update-ToDo-Screen
vargaat Oct 3, 2025
80a6c59
Fix flaky test.
vargaat Oct 3, 2025
38b1433
Optimize course lookup.
vargaat Oct 7, 2025
07d850d
Merge branch 'master' into feature/MBL-19373-Update-ToDo-Screen
vargaat Oct 14, 2025
92cc847
Implement code review suggestions. Improve a11y.
vargaat Oct 15, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
16 changes: 13 additions & 3 deletions Core/Core/Common/CommonUI/InstUI/Views/Divider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
//

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())
}
}
19 changes: 19 additions & 0 deletions Core/Core/Common/Extensions/Foundation/DateExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -276,13 +285,23 @@ 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.
*/
var dayInMonth: String {
Date.dayInMonthFormatter.string(from: self)
}

/**
E.g.: 6
*/
var dayString: String {
Date.dayFormatter.string(from: self)
}
Expand Down
3 changes: 3 additions & 0 deletions Core/Core/Features/Courses/APICourse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions Core/Core/Features/Courses/Course.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CourseSection>
@NSManaged public var syllabusBody: String?
@NSManaged public var termName: String?
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
105 changes: 75 additions & 30 deletions Core/Core/Features/Todos/Model/TodoInteractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,64 +20,109 @@ import Foundation
import Combine

public protocol TodoInteractor {
typealias IsEmptyState = Bool
var todos: AnyPublisher<[TodoItem], Never> { get }
func refresh(ignoreCache: Bool) -> AnyPublisher<IsEmptyState, Error>
var todoGroups: AnyPublisher<[TodoGroupViewModel], Never> { get }
func refresh(ignoreCache: Bool) -> AnyPublisher<Void, Error>
}

public final class TodoInteractorLive: TodoInteractor {
public var todos: AnyPublisher<[TodoItem], Never> {
todosSubject.eraseToAnyPublisher()
public var todoGroups: AnyPublisher<[TodoGroupViewModel], Never> {
todoGroupsSubject.eraseToAnyPublisher()
}

private let todosSubject = CurrentValueSubject<[TodoItem], Never>([])
private let startDate: Date
private let endDate: Date
private let todoGroupsSubject = CurrentValueSubject<[TodoGroupViewModel], Never>([])
private let env: AppEnvironment

private var subscriptions = Set<AnyCancellable>()

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<IsEmptyState, Error> {
ReactiveStore(useCase: GetCourses())
public func refresh(ignoreCache: Bool) -> AnyPublisher<Void, Error> {
let startDate = Clock.now.addDays(-28)
let endDate = Clock.now.addDays(28)
let currentUserID = env.currentSession?.userID

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 {
.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 { codes in
return ReactiveStore(useCase: GetPlannables(startDate: self.startDate, endDate: self.endDate, contextCodes: codes))
.getEntities(ignoreCache: ignoreCache, loadAllPages: true)
.map { $0.compactMap(TodoItem.init) }
.flatMap { [env] (courseContextCodes, courses: [Course]) in
ReactiveStore(
useCase: GetPlannables(startDate: startDate, endDate: endDate, contextCodes: courseContextCodes),
environment: env
)
.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)
}
}
}
.map { [weak self] in
self?.todosSubject.value = $0
return $0.isEmpty
.map { [weak todoGroupsSubject] (todos: [TodoItemViewModel]) in
TabBarBadgeCounts.todoListCount = UInt(todos.count)

// Group todos by day
let groupedTodos = Self.groupTodosByDay(todos)
todoGroupsSubject?.value = groupedTodos
return ()
}
.eraseToAnyPublisher()
}

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()
}

// Convert to TodoGroup array and sort by date
return groupedDict.map { (date, items) in
TodoGroupViewModel(date: date, items: items.sorted())
}
.sorted()
}
}

#if DEBUG

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

public init(todoGroups: [TodoGroupViewModel]? = nil) {
if let todoGroups {
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 ? [.make(id: "1"), .make(id: "2")] : todos
self.todos = Publishers.typedJust(todos)
let todayGroup = TodoGroupViewModel(
date: today,
items: [
.makeShortText(id: "3")
]
)
let tomorrowGroup = TodoGroupViewModel(
date: tomorrow,
items: [
.makeShortText(id: "1"),
.makeLongText(id: "2")
]
)
self.todoGroups = Publishers.typedJust([todayGroup, tomorrowGroup])
}

public func refresh(ignoreCache: Bool) -> AnyPublisher<IsEmptyState, Error> {
Publishers.typedJust(false)
public func refresh(ignoreCache: Bool) -> AnyPublisher<Void, Error> {
Publishers.typedJust(())
}
}

Expand Down
Loading