Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
782fdb4
Add new date formatting helpers.
vargaat Nov 5, 2025
1624b7b
Add primitives for filtering.
vargaat Nov 5, 2025
64fc7e1
Add persistence for filter options.
vargaat Nov 5, 2025
02d9c8f
[MBL-19375] Add Todo filter screen UI and ViewModel
vargaat Nov 6, 2025
ec11ae9
Use router.show for filter screen and optimize callbacks
vargaat Nov 6, 2025
e93bf63
Merge branch 'master' into feature/MBL-19375-todo-filters
vargaat Nov 6, 2025
bb3bedc
Fix account plannables not being recognized.
vargaat Nov 7, 2025
40ee45b
Refactor Todo filtering to use local filtering with cache-aware loading
vargaat Nov 7, 2025
e260674
Update GetPlannables to allow fetching without any context codes.
vargaat Nov 10, 2025
b408c62
Merge branch 'master' into feature/MBL-19375-todo-filters
vargaat Nov 17, 2025
f5866c9
Merge branch 'master' into feature/MBL-19375-todo-filters
vargaat Nov 17, 2025
330545f
Refactor swipe gesture to support configurable completion behavior
vargaat Nov 17, 2025
2024e8c
Update unit tests.
vargaat Nov 18, 2025
5be3782
Add analytics events for todo list filter loading
vargaat Nov 18, 2025
d0cb440
Cleanup.
vargaat Nov 18, 2025
929f91e
Add support to display all day and time interval based events.
vargaat Nov 18, 2025
ae711ee
Merge branch 'master' into feature/MBL-19375-todo-filters
vargaat Nov 18, 2025
00214e8
Fix swipe action bug and refactor to item-level behavior
vargaat Nov 19, 2025
c5dd157
Fix To-do scroll not working on iOS 26.
vargaat Nov 19, 2025
cae0e51
Fix UI state not being in sync with DB.
vargaat Nov 19, 2025
6e55160
Isolate Plannable objects used for Todo items from Calendar Plannables.
vargaat Nov 20, 2025
07c15e8
Make account events non-tappable.
vargaat Nov 20, 2025
e0cf63a
Code cleanup.
vargaat Nov 20, 2025
0ef41cb
Refresh todo list on app foreground events.
vargaat Nov 20, 2025
50ada6f
Fix GetPlannablesTests cache key
vargaat Nov 20, 2025
508277f
Fix test timing and locale issues
vargaat Nov 20, 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 @@ -223,7 +223,23 @@ public struct SessionDefaults: Equatable {
}
}

// MARK: Offline Settings -
// MARK: - Todo List Settings

public var todoFilterOptions: TodoFilterOptions? {
get {
guard let data = self["todoFilterOptions"] as? Data else {
return nil
}
return try? JSONDecoder().decode(TodoFilterOptions.self, from: data)
}
set {
if let newValue, let data = try? JSONEncoder().encode(newValue) {
self["todoFilterOptions"] = data
} else {
self["todoFilterOptions"] = nil
}
}
}

public mutating func reset() {
sessionDefaults = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,49 +18,75 @@

import SwiftUI

extension InstUI {

public enum SwipeCompletionBehavior: Equatable {
/// Swipe action remains fully revealed after swipe completion. Gesture is disabled after action is triggered.
case stayOpen
/// Swipe action immediately resets to closed position after swipe completion. Gesture remains enabled for repeated use.
case reset
}
}

extension View {

/// Adds a swipe-to-remove gesture that reveals an action view when swiping left.
/// Adds a swipe action 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.
/// - completionBehavior: Determines what happens after the swipe action completes. Defaults to `.stayOpen`.
/// - isSwiping: 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.
/// - isEnabled: Whether the swipe gesture is enabled. Defaults to `true`.
/// - onSwipeCommitted: Optional closure called immediately when the swipe action is committed (user releases after reaching threshold) but before animations finish.
/// - onSwipe: Closure called when the swipe action is completed animations included. For `.reset` behavior, this is called after the close animation finishes. For `.stayOpen` behavior, this is called while the open animation is running.
/// - label: The view displayed in the revealed area during the swipe.
public func swipeToRemove<Label: View>(
public func swipeAction<Label: View>(
backgroundColor: Color,
completionBehavior: InstUI.SwipeCompletionBehavior = .stayOpen,
isSwiping: Binding<Bool> = .constant(false),
isEnabled: Bool = true,
onSwipeCommitted: (() -> Void)? = nil,
onSwipe: @escaping () -> Void,
@ViewBuilder label: @escaping () -> Label
) -> some View {
modifier(SwipeToRemoveModifier(
modifier(SwipeActionModifier(
backgroundColor: backgroundColor,
completionBehavior: completionBehavior,
isSwiping: isSwiping,
isEnabled: isEnabled,
onSwipeCommitted: onSwipeCommitted,
onSwipe: onSwipe,
label: label
))
}
}

private struct SwipeToRemoveModifier<Label: View>: ViewModifier {
private struct SwipeActionModifier<Label: View>: ViewModifier {
let backgroundColor: Color
let completionBehavior: InstUI.SwipeCompletionBehavior
@Binding var isSwiping: Bool
let isEnabled: Bool
let onSwipeCommitted: (() -> Void)?
let onSwipe: () -> Void
let label: () -> Label

init(
backgroundColor: Color,
completionBehavior: InstUI.SwipeCompletionBehavior,
isSwiping: Binding<Bool>,
isEnabled: Bool,
onSwipeCommitted: (() -> Void)?,
onSwipe: @escaping () -> Void,
label: @escaping () -> Label
) {
self.backgroundColor = backgroundColor
self.completionBehavior = completionBehavior
self._isSwiping = isSwiping
self.isEnabled = isEnabled
self.onSwipeCommitted = onSwipeCommitted
self.onSwipe = onSwipe
self.label = label
}
Expand Down Expand Up @@ -99,10 +125,11 @@ private struct SwipeToRemoveModifier<Label: View>: ViewModifier {
.contentShape(Rectangle())
// If this is a simple gesture and the cell is a button then swiping won't work
.simultaneousGesture(
DragGesture()
// Values lower than 20 will prevent parent gesture recognizers from working. (Parent scrollview cannot be scrolled when tapped on an element having this swipe modifier.)
DragGesture(minimumDistance: 20)
.onChanged(handleDragChanged)
.onEnded(handleDragEnded),
isEnabled: !isActionInvoked
isEnabled: isEnabled && !isActionInvoked
)
}

Expand Down Expand Up @@ -181,9 +208,23 @@ private struct SwipeToRemoveModifier<Label: View>: ViewModifier {
isSwiping = false

if isActionThresholdReached {
animateToOpenedState()
isActionInvoked = true
onSwipe()
onSwipeCommitted?()

switch completionBehavior {
case .stayOpen:
animateToOpenedState()
isActionInvoked = true
onSwipe()
case .reset:
animateToClosedState()
Task { @MainActor in
// Wait for close animation to complete before invoking callback.
// This prevents visual state changes during the animation
// Note: await suspends the Task but does NOT block the main thread
try? await Task.sleep(nanoseconds: 430_000_000)
onSwipe()
}
}
} else {
animateToClosedState()
}
Expand Down Expand Up @@ -221,8 +262,9 @@ private struct SwipeToRemoveModifier<Label: View>: ViewModifier {
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.backgroundLightest)
.swipeToRemove(
.swipeAction(
backgroundColor: .backgroundSuccess,
completionBehavior: .reset,
onSwipe: {},
label: {
Image(systemName: "checkmark.circle.fill")
Expand Down
18 changes: 18 additions & 0 deletions Core/Core/Common/Extensions/Foundation/DateExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ public extension Date {
?? Date()
}

func addWeeks(_ weeks: Int) -> Date {
Cal.currentCalendar.date(byAdding: .weekOfYear, value: weeks, to: self)
?? Date()
}

func addDays(_ days: Int) -> Date {
Cal.currentCalendar.date(byAdding: .day, value: days, to: self)
?? Date()
Expand Down Expand Up @@ -202,6 +207,12 @@ public extension Date {
return formatter
}()

private static var shortDayMonthFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate("dMMM")
return formatter
}()

private static var timeOnlyFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
Expand Down Expand Up @@ -306,6 +317,13 @@ public extension Date {
Date.dayFormatter.string(from: self)
}

/**
E.g.: 6 Sep
*/
var shortDayMonth: String {
Date.shortDayMonthFormatter.string(from: self)
}

var timeString: String {
Date.timeOnlyFormatter.string(from: self)
}
Expand Down
4 changes: 4 additions & 0 deletions Core/Core/Common/Extensions/InstIconExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ public extension UIImage {
static var dashboardSolid: UIImage { UIImage(named: "dashboardSolid", in: .core, compatibleWith: nil)! }
static var discussionLine: UIImage { UIImage(named: "discussionLine", in: .core, compatibleWith: nil)! }
static var discussionSolid: UIImage { UIImage(named: "discussionSolid", in: .core, compatibleWith: nil)! }
static var discussionReply2Line: UIImage { UIImage(named: "discussionReply2Line", in: .core, compatibleWith: nil)! }
static var discussionReply2Solid: UIImage { UIImage(named: "discussionReply2Solid", in: .core, compatibleWith: nil)! }
static var documentLine: UIImage { UIImage(named: "documentLine", in: .core, compatibleWith: nil)! }
static var documentSolid: UIImage { UIImage(named: "documentSolid", in: .core, compatibleWith: nil)! }
static var editLine: UIImage { UIImage(named: "editLine", in: .core, compatibleWith: nil)! }
Expand Down Expand Up @@ -345,6 +347,8 @@ public extension Image {
static var dashboardSolid: Image { Image("dashboardSolid", bundle: .core) }
static var discussionLine: Image { Image("discussionLine", bundle: .core) }
static var discussionSolid: Image { Image("discussionSolid", bundle: .core) }
static var discussionReply2Line: Image { Image("discussionReply2Line", bundle: .core) }
static var discussionReply2Solid: Image { Image("discussionReply2Solid", bundle: .core) }
static var documentLine: Image { Image("documentLine", bundle: .core) }
static var documentSolid: Image { Image("documentSolid", bundle: .core) }
static var editLine: Image { Image("editLine", bundle: .core) }
Expand Down
4 changes: 4 additions & 0 deletions Core/Core/Features/Planner/Model/API/APIPlannable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import Foundation

public struct APIPlannable: Codable, Equatable {
let account_id: ID?
let course_id: ID?
let group_id: ID?
let user_id: ID?
Expand Down Expand Up @@ -56,6 +57,7 @@ public struct APIPlannable: Codable, Equatable {
case .course: Context(.course, id: course_id?.rawValue)
case .group: Context(.group, id: group_id?.rawValue)
case .user: Context(.user, id: user_id?.rawValue)
case .account: Context(.account, id: account_id?.rawValue)
default: nil
}
}
Expand Down Expand Up @@ -113,6 +115,7 @@ public struct APIPlannerOverride: Codable, Equatable {
#if DEBUG
extension APIPlannable {
public static func make(
account_id: ID? = nil,
course_id: ID? = "1",
group_id: ID? = nil,
user_id: ID? = nil,
Expand All @@ -128,6 +131,7 @@ extension APIPlannable {
details: APIPlannable.Details? = nil
) -> APIPlannable {
return APIPlannable(
account_id: account_id,
course_id: course_id,
group_id: group_id,
user_id: user_id,
Expand Down
9 changes: 9 additions & 0 deletions Core/Core/Features/Planner/Model/CoreData/Plannable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public enum PlannableType: String, Codable {

public enum PlannableUseCaseID: String, Codable {
case syllabusSummary
case todo
}

public final class Plannable: NSManagedObject {
Expand All @@ -40,6 +41,8 @@ public final class Plannable: NSManagedObject {
@NSManaged public var canvasContextIDRaw: String?
@NSManaged public var contextName: String?
@NSManaged public var date: Date?
@NSManaged public var isAllDay: Bool
@NSManaged public var endAt: Date?
@NSManaged public var hasDate: Bool
@NSManaged public var pointsPossibleRaw: NSNumber?
@NSManaged public var userID: String?
Expand Down Expand Up @@ -87,6 +90,8 @@ public final class Plannable: NSManagedObject {
model.title = item.plannable?.title
model.date = item.plannable_date
model.hasDate = true
model.isAllDay = item.plannable?.all_day ?? false
model.endAt = item.plannable?.end_at
model.pointsPossible = item.plannable?.points_possible
model.details = item.plannable?.details
model.context = item.context
Expand Down Expand Up @@ -137,6 +142,8 @@ public final class Plannable: NSManagedObject {
model.contextName = item.context_name
model.date = item.start_at
model.hasDate = item.start_at != nil
model.isAllDay = item.all_day
model.endAt = item.end_at
model.pointsPossible = item.assignment?.points_possible
model.details = item.description
model.userID = userId
Expand Down Expand Up @@ -207,6 +214,8 @@ extension Plannable {
} else {
if let color: ContextColor = managedObjectContext?.first(where: #keyPath(ContextColor.canvasContextID), equals: canvasContextID) {
return color.color
} else if context?.contextType == .account {
return Brand.shared.primary
} else {
return .textDark
}
Expand Down
37 changes: 28 additions & 9 deletions Core/Core/Features/Planner/Model/UseCase/GetPlannables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,38 @@ public class GetPlannables: UseCase {
var endDate: Date
var contextCodes: [String]?
var filter: String = ""
var allowEmptyContextCodesFetch: Bool = false
var useCaseID: PlannableUseCaseID?

let observerEvents = PassthroughSubject<EventsRequest, Never>()
var subscriptions = Set<AnyCancellable>()

public init(userID: String? = nil, startDate: Date, endDate: Date, contextCodes: [String]? = nil, filter: String = "") {
/// - parameters:
/// - useCaseID: When set, filters and tags plannables instance on saving with this use case ID for data isolation.
public init(
userID: String? = nil,
startDate: Date,
endDate: Date,
contextCodes: [String]? = nil,
filter: String = "",
allowEmptyContextCodesFetch: Bool = false,
useCaseID: PlannableUseCaseID? = nil
) {
self.userID = userID
self.startDate = startDate
self.endDate = endDate
self.contextCodes = contextCodes
self.filter = filter
self.allowEmptyContextCodesFetch = allowEmptyContextCodesFetch
self.useCaseID = useCaseID

setupObserverEventsSubscription()
}

public var cacheKey: String? {
let codes = contextCodes?.joined(separator: ",") ?? ""
return "get-plannables-\(userID ?? "")-\(startDate)-\(endDate)-\(filter)-\(codes)"
let useCaseIDString = useCaseID?.rawValue ?? "nil"
return "get-plannables-\(userID ?? "")-\(startDate)-\(endDate)-\(filter)-\(codes)-\(useCaseIDString)"
}

public var scope: Scope {
Expand All @@ -61,10 +76,10 @@ public class GetPlannables: UseCase {
startDate as NSDate, #keyPath(Plannable.date),
#keyPath(Plannable.date), endDate as NSDate
),
NSPredicate(format: "%K == nil", #keyPath(Plannable.originUseCaseIDRaw))
NSPredicate(key: #keyPath(Plannable.originUseCaseIDRaw), equals: useCaseID?.rawValue)
]

if let userID = userID {
if let userID {
subPredicates.append(
NSPredicate(key: #keyPath(Plannable.userID), equals: userID)
)
Expand All @@ -87,8 +102,12 @@ public class GetPlannables: UseCase {
}

public func makeRequest(environment: AppEnvironment, completionHandler: @escaping RequestCallback) {
// If we would send out the request without any context codes the API would return all events so we do an early exit
if (contextCodes ?? []).isEmpty {
// If we would send out the request without any context codes the API would return all events.
// ^ Old comment
//
// Only return empty if allowEmptyContextCodesFetch is false. Student To-do needs to fetch all plannables
// without context codes so that's why the allowEmptyContextCodesFetch param was introduced.
Comment on lines +105 to +109
Copy link
Contributor

Choose a reason for hiding this comment

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

IMHO it would make sense to combine the old and new comments, as it is somewhat confusing this way.

if !allowEmptyContextCodesFetch, (contextCodes ?? []).isEmpty {
completionHandler(.empty, nil, nil)
return
}
Expand Down Expand Up @@ -126,15 +145,15 @@ public class GetPlannables: UseCase {
let plannerNoteItems: [APIPlannerNote] = response?.plannerNotes ?? []

for item in plannableItems where item.plannableType != .announcement {
Plannable.save(item, userId: userID, in: client)
Plannable.save(item, userId: userID, useCase: useCaseID, in: client)
}

for item in calendarEventItems where item.hidden != true {
Plannable.save(item, userId: userID, in: client)
Plannable.save(item, userId: userID, useCase: useCaseID, in: client)
}

for item in plannerNoteItems {
Plannable.save(item, contextName: nil, in: client)
Plannable.save(item, contextName: nil, useCase: useCaseID, in: client)
}
}
}
Loading