- 
                Notifications
    You must be signed in to change notification settings 
- Fork 121
[MBL-19374][S] Add mark as done to To-do screen #3722
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
          
     Open
      
      
            vargaat
  wants to merge
  53
  commits into
  master
  
    
      
        
          
  
    
      Choose a base branch
      
     
    
      
        
      
      
        
          
          
        
        
          
            
              
              
              
  
           
        
        
          
            
              
              
           
        
       
     
  
        
          
            
          
            
          
        
       
    
      
from
feature/MBL-19374-Add-mark-as-done-to-todo
  
      
      
   
  
    
  
  
  
 
  
      
    base: master
Could not load branches
            
              
  
    Branch not found: {{ refName }}
  
            
                
      Loading
              
            Could not load tags
            
            
              Nothing to show
            
              
  
            
                
      Loading
              
            Are you sure you want to change the base?
            Some commits from the old base branch may be removed from the timeline,
            and old review comments may become outdated.
          
          
  
     Open
                    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 92a4f83
              
                Remove isEmpty state publishing.
              
              
                vargaat 292f4f9
              
                Add tab and application badge update logic.
              
              
                vargaat 5c2ac15
              
                Use +-28 days interval for todo fetching.
              
              
                vargaat 1feaef4
              
                Use local env variable for fetching.
              
              
                vargaat 7a4813a
              
                Extract todo cell content view to Core.
              
              
                vargaat dbc7b04
              
                Update todo item layout.
              
              
                vargaat 9d217cc
              
                Use shared todo cells for widget and todo screen.
              
              
                vargaat 892aaf9
              
                Add sticky day headers.
              
              
                vargaat cbbe33b
              
                Update layout.
              
              
                vargaat 7a17678
              
                Route to calendar when tapping on the day header.
              
              
                vargaat 7ac197a
              
                Improve a11y.
              
              
                vargaat 4ee2924
              
                Move entities to the view model level.
              
              
                vargaat 3a9c5c1
              
                Continue renaming.
              
              
                vargaat b8d723f
              
                Move sorting logic to view model entities.
              
              
                vargaat 74c390b
              
                Update unit tests.
              
              
                vargaat 6bed69e
              
                Update layout.
              
              
                vargaat e45f352
              
                Display only time
              
              
                vargaat 474f0c4
              
                Update empty state.
              
              
                vargaat d63b8c6
              
                Update course name lookup logic.
              
              
                vargaat 9cb87cf
              
                Merge branch 'master' into feature/MBL-19373-Update-ToDo-Screen
              
              
                vargaat 465b4b6
              
                Update public previews.
              
              
                vargaat 7e0273c
              
                Update dividers for better section scrolling experience.
              
              
                vargaat da84fa7
              
                Merge branch 'master' into feature/MBL-19373-Update-ToDo-Screen
              
              
                vargaat 80a6c59
              
                Fix flaky test.
              
              
                vargaat 38b1433
              
                Optimize course lookup.
              
              
                vargaat 07d850d
              
                Merge branch 'master' into feature/MBL-19373-Update-ToDo-Screen
              
              
                vargaat 92cc847
              
                Implement code review suggestions. Improve a11y.
              
              
                vargaat 6b51e2a
              
                Add DB fields required for mark as done feature.
              
              
                vargaat 8354bed
              
                Add MarkPlannableItemDone use case with tests
              
              
                vargaat b3aab41
              
                Convert TodoItemViewModel to class with mark-as-done state
              
              
                vargaat 6064518
              
                Add checkbox and swipe-to-done to TodoListItemCell
              
              
                vargaat 4fe6b5f
              
                Add mark-as-done logic to TodoListViewModel
              
              
                vargaat 7c4b968
              
                Add filtering for completed items in TodoInteractor
              
              
                vargaat 080618d
              
                Add snackbar error handling for mark-as-done
              
              
                vargaat ac9aa6d
              
                Add unit tests for mark-as-done functionality
              
              
                vargaat 69bba47
              
                Add accessibility identifier for checkbox
              
              
                vargaat d3626ea
              
                Fix refresh issues. Update async tests.
              
              
                vargaat db4fda5
              
                Refactor mark-as-done to use interactor pattern
              
              
                vargaat e613c31
              
                Add swipe gesture.
              
              
                vargaat 30ff3c0
              
                Merge branch 'master' into feature/MBL-19374-Add-mark-as-done-to-todo
              
              
                vargaat d35a7b7
              
                Add swipe to done feature.
              
              
                vargaat 4080d54
              
                Rename id to plannableId.
              
              
                vargaat 1fd77d0
              
                Reset view identity on restoration and force refresh.
              
              
                vargaat cfc5731
              
                Prevent scrolling while swipe is in action.
              
              
                vargaat 3bf743c
              
                Fine tune gesture properties.
              
              
                vargaat b847f52
              
                Add accessibility support for mark as done feature.
              
              
                vargaat 44d8134
              
                Use tap gesture instead of button to avoid gesture recognizer conflicts.
              
              
                vargaat 87977d9
              
                Tweak gesture recognizer to better separate vertical and horizontal g…
              
              
                vargaat bdb9166
              
                Add analytics events.
              
              
                vargaat d823362
              
                Code cleanup.
              
              
                vargaat 9f3fadd
              
                Refactor timer management and fix API response handling.
              
              
                vargaat f083389
              
                Remove unnecessary HStack and public modifiers. Add preview for swipe…
              
              
                vargaat File filter
Filter by extension
Conversations
          Failed to load comments.   
        
        
          
      Loading
        
  Jump to
        
          Jump to file
        
      
      
          Failed to load files.   
        
        
          
      Loading
        
  Diff view
Diff view
There are no files selected for viewing
        
          
  
    
      
          
            227 changes: 227 additions & 0 deletions
          
          227 
        
  Core/Core/Common/CommonUI/InstUI/Utils/SwipeToRemove.swift
  
  
      
      
   
        
      
      
    
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | 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>? | ||
| 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 | ||
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | 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) | ||
| } | ||
| } | 
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              
      
      Oops, something went wrong.
        
    
  
  Add this suggestion to a batch that can be applied as a single commit.
  This suggestion is invalid because no changes were made to the code.
  Suggestions cannot be applied while the pull request is closed.
  Suggestions cannot be applied while viewing a subset of changes.
  Only one suggestion per line can be applied in a batch.
  Add this suggestion to a batch that can be applied as a single commit.
  Applying suggestions on deleted lines is not supported.
  You must change the existing code in this line in order to create a valid suggestion.
  Outdated suggestions cannot be applied.
  This suggestion has been applied or marked resolved.
  Suggestions cannot be applied from pending reviews.
  Suggestions cannot be applied on multi-line comments.
  Suggestions cannot be applied while the pull request is queued to merge.
  Suggestion cannot be applied right now. Please check back later.
  
    
  
    
There was a problem hiding this comment.
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?