Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 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
6b51e2a
Add DB fields required for mark as done feature.
vargaat Oct 8, 2025
8354bed
Add MarkPlannableItemDone use case with tests
vargaat Oct 15, 2025
b3aab41
Convert TodoItemViewModel to class with mark-as-done state
vargaat Oct 15, 2025
6064518
Add checkbox and swipe-to-done to TodoListItemCell
vargaat Oct 16, 2025
4fe6b5f
Add mark-as-done logic to TodoListViewModel
vargaat Oct 16, 2025
7c4b968
Add filtering for completed items in TodoInteractor
vargaat Oct 16, 2025
080618d
Add snackbar error handling for mark-as-done
vargaat Oct 16, 2025
ac9aa6d
Add unit tests for mark-as-done functionality
vargaat Oct 16, 2025
69bba47
Add accessibility identifier for checkbox
vargaat Oct 16, 2025
d3626ea
Fix refresh issues. Update async tests.
vargaat Oct 16, 2025
db4fda5
Refactor mark-as-done to use interactor pattern
vargaat Oct 16, 2025
e613c31
Add swipe gesture.
vargaat Oct 21, 2025
30ff3c0
Merge branch 'master' into feature/MBL-19374-Add-mark-as-done-to-todo
vargaat Oct 21, 2025
d35a7b7
Add swipe to done feature.
vargaat Oct 21, 2025
4080d54
Rename id to plannableId.
vargaat Oct 21, 2025
1fd77d0
Reset view identity on restoration and force refresh.
vargaat Oct 21, 2025
cfc5731
Prevent scrolling while swipe is in action.
vargaat Oct 21, 2025
3bf743c
Fine tune gesture properties.
vargaat Oct 21, 2025
b847f52
Add accessibility support for mark as done feature.
vargaat Oct 21, 2025
44d8134
Use tap gesture instead of button to avoid gesture recognizer conflicts.
vargaat Oct 21, 2025
87977d9
Tweak gesture recognizer to better separate vertical and horizontal g…
vargaat Oct 21, 2025
bdb9166
Add analytics events.
vargaat Oct 22, 2025
d823362
Code cleanup.
vargaat Oct 22, 2025
9f3fadd
Refactor timer management and fix API response handling.
vargaat Oct 22, 2025
f083389
Remove unnecessary HStack and public modifiers. Add preview for swipe…
vargaat Oct 22, 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
227 changes: 227 additions & 0 deletions Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
//
// 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 extension View {

/// 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<Label: View>(
backgroundColor: Color,
isSwiping: Binding<Bool>? = nil,
onSwipe: @escaping () -> Void,
@ViewBuilder label: @escaping () -> Label
) -> some View {
modifier(SwipeToRemoveModifier(
backgroundColor: backgroundColor,
isSwiping: isSwiping,
onSwipe: onSwipe,
label: label
))
}
}

private struct SwipeToRemoveModifier<Label: View>: ViewModifier {
let backgroundColor: Color
let isSwiping: Binding<Bool>?
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not using @Binding var isSwiping: Bool ?

let onSwipe: () -> Void
let label: () -> Label

// MARK: - Gesture Properties
/// 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
@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
/// 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)

func body(content: Content) -> some View {
ZStack(alignment: .trailing) {
swipeBackground
content.offset(x: cellContentOffset).clipped()
}
.onWidthChange { width in
cellWidth = width
actionThreshold = cellWidth * actionThresholdRatio
}
.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),
isEnabled: !isActionInvoked
)
}

private var swipeBackground: some View {
backgroundColor
.overlay(alignment: .trailing) {
label()
.onWidthChange { width in
actionViewWidth = width
actionViewOffset = width
}
.offset(x: actionViewOffset)
}
.animation(.smooth, value: actionViewOffset)
.accessibilityHidden(true)
}

// MARK: - Drag In Progress

private func handleDragChanged(_ value: DragGesture.Value) {
if isStartedAsHorizontalGesture == nil {
isStartedAsHorizontalGesture = value.translation.isHorizontalSwipe
}

guard isStartedAsHorizontalGesture == true,
value.translation.isSwipingLeft
else { return }

isSwiping?.wrappedValue = true

hapticGenerator.prepare()
cellContentOffset = max(value.translation.width, -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) {
defer { isStartedAsHorizontalGesture = nil }
guard isStartedAsHorizontalGesture == true else { return }

isSwiping?.wrappedValue = false

if isActionThresholdReached {
animateToOpenedState()
isActionInvoked = true
onSwipe()
} else {
animateToClosedState()
}
}

private func animateToOpenedState() {
withAnimation(.smooth) {
cellContentOffset = -cellWidth
actionViewOffset = -cellWidth + actionViewWidth
}
}

private func animateToClosedState() {
isActionThresholdReached = false
withAnimation(.smooth) {
cellContentOffset = 0
actionViewOffset = actionViewWidth
}
}
}

#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
17 changes: 17 additions & 0 deletions Core/Core/Common/CommonUI/SwiftUIViews/View+MeasuringSize.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CGFloat>) -> some View {
onGeometryChange(for: CGFloat.self) { geometry in
geometry.size.width
} action: { height in
binding.wrappedValue = height
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
89 changes: 89 additions & 0 deletions Core/Core/Features/Planner/MarkPlannableItemDone.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//
// 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 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, callback: completionHandler)
}

private func updateExistingOverride(overrideId: String, environment: AppEnvironment, completionHandler: @escaping RequestCallback) {
let request = UpdatePlannerOverrideRequest(
overrideId: overrideId,
body: .init(marked_complete: done)
)
environment.api.makeRequest(request, callback: completionHandler)
}
}
2 changes: 1 addition & 1 deletion Core/Core/Features/Planner/Model/API/APIPlannable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
11 changes: 11 additions & 0 deletions Core/Core/Features/Planner/Model/CoreData/Plannable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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
}

Expand Down
Loading