Repository: edx/edx-mobile-marketplace-ios
Pull request #141: feat: course messaging improvements
Author: login: mta452
Created: 2025-04-29T12:41:14Z | Merged: 2025-05-14T08:13:57Z

[LEARNER-10505](https://2u-internal.atlassian.net/browse/LEARNER-10505): Course messaging improvements

## Description

This PR improves the user experience on the course screen by making all banners dismissible and persisting their dismissal state for 24 hours.

### What's Changed
  - All banners now include a dismiss (`✕`) button at the top-right corner.
  - Dismissing a banner hides it immediately.
  - Dismissed state persists for **24 hours** using **local storage**.
  - Dismissal events are tracked.
  - **Titles:**
      - **INFO_BANNER:** Learn at Your Own Pace
      - **UPGRADE_TO_GRADED:** Unlock Graded Assignments
      - **UPGRADE_TO_RESET:** Upgrade for Full Access
      - **RESET_DATES:** Missed some deadlines?
  - **Description:**
      - **UPGRADE_TO_RESET:** You're auditing this course and can't submit graded assignments. To access grading and shift missed deadlines, upgrade now.

## Preview
**iPhone**
| Banner Type | Portrait - Light | Portrait - Dark | Landscape - Light & Dark |
| - | - | - | - |
| Reset Dates | ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 46 04](https://github.com/user-attachments/assets/c1b4661e-da0e-43f4-80fc-dd172cf996bc) | ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 46 08](https://github.com/user-attachments/assets/68b750aa-2d7b-499d-821a-15c80910098f) | ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 46 13](https://github.com/user-attachments/assets/54d2881a-a9a7-4508-ac74-730edcb9278f) ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 46 21](https://github.com/user-attachments/assets/c19f6e60-af62-421c-a990-a0b881811375) |
| Upgrade to Graded | ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 38 44](https://github.com/user-attachments/assets/25504f37-d1c1-4bdf-9e6b-a91ccb088ba8) | ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 38 51](https://github.com/user-attachments/assets/7d0886bc-b4ce-48d3-af32-e7d7962308ce) | ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 39 13](https://github.com/user-attachments/assets/7860c5b2-1bb9-4a52-a830-9ac034c4c6d6) ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 39 07](https://github.com/user-attachments/assets/51308404-600d-4fb9-9fde-83203d054a7f) |
| Info Banner | ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 41 45](https://github.com/user-attachments/assets/70d5827b-9f4c-4904-9a5d-155fa1e0a4de) | ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 41 51](https://github.com/user-attachments/assets/1f3d14cc-e41a-4f37-8452-ee76404b13e1) | ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 41 59](https://github.com/user-attachments/assets/bc00306e-af58-4525-91b8-f13f141f8f2f) ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 42 03](https://github.com/user-attachments/assets/15e2677f-a018-4d3e-9cac-ddce047855ca) |
| Upgrade to Reset | ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 44 01](https://github.com/user-attachments/assets/a2d3ac07-ec5f-429d-a9e8-f4b79189cf3e) | ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 44 15](https://github.com/user-attachments/assets/fab71033-e43c-4ccc-8181-b6f94b3c86e2) |  ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 44 20](https://github.com/user-attachments/assets/8ed10c61-9d95-4e55-aaf6-0419af129a98) ![Simulator Screenshot - iPhone 16 Pro Max - 2025-04-29 at 15 44 26](https://github.com/user-attachments/assets/ccf42364-5eb1-4572-bb46-0e2060197bbe) |

**iPad**
| Orientation | Light Theme | Dark Theme |
| - | - | - |
| Portrait | ![Simulator Screenshot - iPad Pro 11-inch (M4) - 2025-04-29 at 16 52 10](https://github.com/user-attachments/assets/c98bbefd-2d1f-4ff9-8c30-99774b7ed96e) | ![Simulator Screenshot - iPad Pro 11-inch (M4) - 2025-04-29 at 16 52 14](https://github.com/user-attachments/assets/84faa71d-6428-4b9e-9563-4f75af063da2) |
| Landscape | ![Simulator Screenshot - iPad Pro 11-inch (M4) - 2025-04-29 at 16 52 23](https://github.com/user-attachments/assets/a6911cc9-6aab-409a-922e-851cc227e2ba) | ![Simulator Screenshot - iPad Pro 11-inch (M4) - 2025-04-29 at 16 52 28](https://github.com/user-attachments/assets/38718605-d7fa-486b-93b1-3517a5dbabad) |



Diff:
diff --git a/Core/Core/Analytics/CoreAnalytics.swift b/Core/Core/Analytics/CoreAnalytics.swift
index 978e1c79b..0303f7e09 100644
--- a/Core/Core/Analytics/CoreAnalytics.swift
+++ b/Core/Core/Analytics/CoreAnalytics.swift
@@ -294,6 +294,7 @@ public enum AnalyticsEvent: String {
     case datesCalendarSyncDialogAction = "Dates:CalendarSync Dialog Action"
     case datesCalendarSyncSnackbar = "Dates:CalendarSync Snackbar"
     case plsBannerViewed = "PLS:Banner Viewed"
+    case plsBannerDismissed = "PLS:Banner Dismissed"
     case plsShiftDatesClicked = "PLS:Shift Button Clicked"
     case plsShiftDatesSuccess = "PLS:Shift Dates Success"
     case courseViewCertificateClicked = "Course:View Certificate Clicked"
@@ -409,6 +410,7 @@ public enum EventBIValue: String {
     case datesCalendarSyncDialogAction = "edx.bi.app.dates.calendar_sync.dialog_action"
     case datesCalendarSyncSnackbar = "edx.bi.app.dates.calendar_sync.snackbar"
     case plsBannerViewed = "edx.bi.app.dates.pls_banner.viewed"
+    case plsBannerDismissed = "edx.bi.app.dates.pls_banner.dismissed"
     case plsShiftDatesClicked = "edx.bi.app.dates.pls_banner.shift_dates.clicked"
     case plsShiftDatesSuccess = "edx.bi.app.dates.pls_banner.shift_dates.success"
     case courseViewCertificateClicked = "edx.bi.app.course.view_certificate.clicked"
diff --git a/Course/Course/Data/CourseStorage.swift b/Course/Course/Data/CourseStorage.swift
index 1bfc64704..dbf7f88c4 100644
--- a/Course/Course/Data/CourseStorage.swift
+++ b/Course/Course/Data/CourseStorage.swift
@@ -8,9 +8,19 @@
 import Foundation
 import Core
 
+public enum CourseBannerType: String {
+    case resetDates = "ResetDates"
+    case upgradeToGraded = "UpgradeToGraded"
+    case infoBanner = "InfoBanner"
+    case upgradeToReset = "UpgradeToReset"
+}
+
 public protocol CourseStorage {
     var allowedDownloadLargeFile: Bool? { get set }
     var userSettings: UserSettings? { get set }
+
+    func dismissalDate(for bannerType: CourseBannerType, courseID: String) -> Date?
+    func setDismissalDate(for bannerType: CourseBannerType, courseID: String, to date: Date?)
 }
 
 #if DEBUG
@@ -21,5 +31,9 @@ public class CourseStorageMock: CourseStorage {
     public var allowedDownloadLargeFile: Bool?
 
     public init() {}
+
+    public func dismissalDate(for bannerType: CourseBannerType, courseID: String) -> Date? { nil }
+
+    public func setDismissalDate(for bannerType: CourseBannerType, courseID: String, to date: Date?) {}
 }
 #endif
diff --git a/Course/Course/Data/Model/Data_CourseDates.swift b/Course/Course/Data/Model/Data_CourseDates.swift
index 2ef5d339b..09cfbb2c7 100644
--- a/Course/Course/Data/Model/Data_CourseDates.swift
+++ b/Course/Course/Data/Model/Data_CourseDates.swift
@@ -138,7 +138,20 @@ public extension DataLayer {
                 ""
             }
         }
-        
+
+        var storageBannerType: CourseBannerType {
+            switch self {
+            case .datesTabInfoBanner:
+                return .infoBanner
+            case .upgradeToCompleteGradedBanner:
+                return .upgradeToGraded
+            case .upgradeToResetBanner:
+                return .upgradeToReset
+            case .resetDatesBanner:
+                return .resetDates
+            }
+        }
+
         var analyticsBannerType: String {
             switch self {
             case .datesTabInfoBanner:
diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift
index 5076b7744..f5cc8a36a 100644
--- a/Course/Course/Domain/CourseInteractor.swift
+++ b/Course/Course/Domain/CourseInteractor.swift
@@ -21,14 +21,25 @@ public protocol CourseInteractorProtocol {
     func getCourseDates(courseID: String) async throws -> CourseDates
     func getCourseDeadlineInfo(courseID: String) async throws -> CourseDateBanner
     func shiftDueDates(courseID: String) async throws
+    func canShowBanner(_ bannerType: CourseBannerType?, forCourse courseID: String) -> Bool
+    func markBannerDismissed(_ bannerType: CourseBannerType, forCourse courseID: String)
 }
 
 public class CourseInteractor: CourseInteractorProtocol {
     
     private let repository: CourseRepositoryProtocol
-    
-    public init(repository: CourseRepositoryProtocol) {
+    private let storage: CourseStorage
+
+    private enum Constants {
+        static let bannerPresentationWaitingHours: Int = 24
+    }
+
+    public init(
+        repository: CourseRepositoryProtocol,
+        storage: CourseStorage
+    ) {
         self.repository = repository
+        self.storage = storage
     }
     
     public func getCourseBlocks(courseID: String) async throws -> CourseStructure {
@@ -213,11 +224,36 @@ public class CourseInteractor: CourseInteractorProtocol {
         }
         return subtitles
     }
+
+    public func canShowBanner(_ bannerType: CourseBannerType?, forCourse courseID: String) -> Bool {
+        guard let bannerType else {
+            return false
+        }
+        guard let dismissalDate = storage.dismissalDate(for: bannerType, courseID: courseID) else {
+            return true
+        }
+
+        let now = Date()
+        let limit = Calendar.current.date(
+            byAdding: .hour,
+            value: Constants.bannerPresentationWaitingHours,
+            to: dismissalDate
+        ) ?? now
+
+        return now > limit
+    }
+
+    public func markBannerDismissed(_ bannerType: CourseBannerType, forCourse courseID: String) {
+        storage.setDismissalDate(for: bannerType, courseID: courseID, to: Date())
+    }
 }
 
 // Mark - For testing and SwiftUI preview
 #if DEBUG
 public extension CourseInteractor {
-    static let mock = CourseInteractor(repository: CourseRepositoryMock())
+    static let mock = CourseInteractor(
+        repository: CourseRepositoryMock(),
+        storage: CourseStorageMock()
+    )
 }
 #endif
diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift
index 5830aa527..c85e38971 100644
--- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift
+++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift
@@ -58,6 +58,7 @@ public class CourseContainerViewModel: BaseCourseViewModel {
     @Published public var selection: Int
     @Published var isShowProgress = true
     @Published var isShowRefresh = false
+    @Published var canShowBanner = false
     @Published var courseStructure: CourseStructure?
     @Published var courseDeadlineInfo: CourseDateBanner?
     @Published var courseVideosStructure: CourseStructure?
@@ -226,7 +227,17 @@ public class CourseContainerViewModel: BaseCourseViewModel {
             shouldHideMenuBar = true
         }
     }
-    
+
+    @MainActor
+    func updateBannerVisibilityStatus(forCourse courseID: String, dateBanner: CourseDateBanner? = nil) {
+        let data = dateBanner ?? courseDeadlineInfo
+
+        canShowBanner = interactor.canShowBanner(
+            data?.datesBannerInfo.status?.storageBannerType,
+            forCourse: courseID
+        )
+    }
+
     @MainActor
     func getCourseBlocks(courseID: String, withProgress: Bool = true) async {
         guard let courseStart, courseStart < Date() else {
@@ -277,6 +288,8 @@ public class CourseContainerViewModel: BaseCourseViewModel {
         guard let courseStart, courseStart < Date() else { return }
         do {
             let courseDeadlineInfo = try await interactor.getCourseDeadlineInfo(courseID: courseID)
+            updateBannerVisibilityStatus(forCourse: courseID, dateBanner: courseDeadlineInfo)
+
             withAnimation {
                 self.courseDeadlineInfo = courseDeadlineInfo
             }
@@ -453,7 +466,24 @@ public class CourseContainerViewModel: BaseCourseViewModel {
             )
         }
     }
-    
+
+    func dismissBanner(forCourse courseID: String) {
+        guard let banner = courseDeadlineInfo?.datesBannerInfo.status else {
+            return
+        }
+
+        interactor.markBannerDismissed(banner.storageBannerType, forCourse: courseID)
+        canShowBanner = false
+
+        analytics.plsEvent(
+            .plsBannerDismissed,
+            bivalue: .plsBannerDismissed,
+            courseID: courseID,
+            screenName: DatesStatusInfoScreen.courseDashbaord.rawValue,
+            type: banner.analyticsBannerType
+        )
+    }
+
     func verticalsBlocksDownloadable(by courseSequential: CourseSequential) -> [CourseBlock] {
         let verticals = downloadableVerticals.filter { verticalState in
             courseSequential.childs.contains(where: { item in
diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift
index 2f9813b50..94250efc7 100644
--- a/Course/Course/Presentation/Dates/CourseDatesView.swift
+++ b/Course/Course/Presentation/Dates/CourseDatesView.swift
@@ -211,14 +211,19 @@ struct CourseDateListView: View {
                         if !courseDates.hasEnded {
                             CalendarSyncView(courseID: courseID, viewModel: viewModel)
                                 .padding(.bottom, 16)
-                            
-                            DatesStatusInfoView(
-                                datesBannerInfo: courseDates.datesBannerInfo,
-                                courseID: courseID,
-                                courseDatesViewModel: viewModel,
-                                screen: .courseDates
-                            )
-                            .padding(.bottom, 16)
+
+                            if viewModel.canShowBanner {
+                                DatesStatusInfoView(
+                                    datesBannerInfo: courseDates.datesBannerInfo,
+                                    courseID: courseID,
+                                    courseDatesViewModel: viewModel,
+                                    screen: .courseDates,
+                                    onDismiss: {
+                                        viewModel.dismissBanner(forCourse: courseID)
+                                    }
+                                )
+                                .padding(.bottom, 16)
+                            }
                         }
                         
                         ForEach(Array(viewModel.sortedStatuses), id: \.self) { status in
@@ -264,6 +269,9 @@ struct CourseDateListView: View {
                 .frame(maxWidth: .infinity, maxHeight: .infinity)
             }
         }
+        .onAppear {
+            viewModel.updateBannerVisibilityStatus()
+        }
     }
 }
 
@@ -546,7 +554,10 @@ fileprivate extension AttributedString {
 struct CourseDatesView_Previews: PreviewProvider {
     static var previews: some View {
         let viewModel = CourseDatesViewModel(
-            interactor: CourseInteractor(repository: CourseRepositoryMock()),
+            interactor: CourseInteractor(
+                repository: CourseRepositoryMock(),
+                storage: CourseStorageMock()
+            ),
             router: CourseRouterMock(),
             cssInjector: CSSInjectorMock(),
             connectivity: Connectivity(),
diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift
index b32e960a6..6520f50dd 100644
--- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift
+++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift
@@ -21,6 +21,7 @@ public class CourseDatesViewModel: ObservableObject {
     
     @Published var isShowProgress = true
     @Published var showError: Bool = false
+    @Published var canShowBanner: Bool = false
     @Published var courseDates: CourseDates?
     @Published var isOn: Bool = false
     @Published var eventState: EventState?
@@ -103,22 +104,33 @@ public class CourseDatesViewModel: ObservableObject {
             courseDates?.statusDatesBlocks.keys.contains($0) ?? false }
         return filteredKeys
     }
-    
+
+    @MainActor
+    func updateBannerVisibilityStatus() {
+        canShowBanner = interactor.canShowBanner(
+            courseDates?.datesBannerInfo.status?.storageBannerType,
+            forCourse: courseID
+        )
+    }
+
     @MainActor
     func getCourseDates(courseID: String) async {
         isShowProgress = true
+
+        defer {
+            isShowProgress = false
+            updateBannerVisibilityStatus()
+        }
+
         do {
             courseDates = try await interactor.getCourseDates(courseID: courseID)
             await getCourseStructure(courseID: courseID)
             if courseDates?.courseDateBlocks == nil {
-                isShowProgress = false
                 courseDates = nil
                 return
             }
-            isShowProgress = false
             addCourseEventsIfNecessary()
         } catch {
-            isShowProgress = false
             courseDates = nil
         }
     }
@@ -428,7 +440,24 @@ extension CourseDatesViewModel {
             )
         )
     }
-    
+
+    func dismissBanner(forCourse courseID: String) {
+        guard let banner = courseDates?.datesBannerInfo.status else {
+            return
+        }
+
+        interactor.markBannerDismissed(banner.storageBannerType, forCourse: courseID)
+        canShowBanner = false
+
+        trackPLSEvent(
+            .plsBannerDismissed,
+            bivalue: .plsBannerDismissed,
+            courseID: courseID,
+            screenName: DatesStatusInfoScreen.courseDates.rawValue,
+            type: banner.analyticsBannerType
+        )
+    }
+
     func logdateComponentTapped(block: CourseDateBlock, supported: Bool) {
         analytics.datesComponentTapped(
             courseId: courseID,
diff --git a/Course/Course/Presentation/Dates/DatesStatusInfoView.swift b/Course/Course/Presentation/Dates/DatesStatusInfoView.swift
index 996a35abd..6a4c5d106 100644
--- a/Course/Course/Presentation/Dates/DatesStatusInfoView.swift
+++ b/Course/Course/Presentation/Dates/DatesStatusInfoView.swift
@@ -21,7 +21,8 @@ struct DatesStatusInfoView: View {
     var courseDatesViewModel: CourseDatesViewModel?
     var courseContainerViewModel: CourseContainerViewModel?
     var screen: DatesStatusInfoScreen
-    
+    var onDismiss: (() -> Void)
+
     @State private var isLoading = false
 
     var body: some View {
@@ -30,11 +31,23 @@ struct DatesStatusInfoView: View {
             let buttonTitle = datesBannerInfo.status?.buttonTitle ?? ""
             Spacer()
             if !header.isEmpty {
-                Text(header)
-                    .frame(maxWidth: .infinity, alignment: .leading)
-                    .font(Theme.Fonts.titleMedium)
-                    .foregroundColor(Theme.Colors.textPrimary)
-                    .padding(.horizontal, 16)
+                HStack {
+                    Text(header)
+                        .frame(maxWidth: .infinity, alignment: .leading)
+
+                    Button(
+                        action: {
+                            onDismiss()
+                        },
+                        label: {
+                            Image(systemName: "xmark")
+                        }
+                    )
+                }
+                .frame(maxWidth: .infinity)
+                .font(Theme.Fonts.titleMedium)
+                .foregroundColor(Theme.Colors.textPrimary)
+                .padding(.horizontal, 16)
             }
             
             Text(datesBannerInfo.status?.body ?? "")
@@ -113,7 +126,8 @@ struct DatesStatusInfoView_Previews: PreviewProvider {
         DatesStatusInfoView(
             datesBannerInfo: datesBannerInfo,
             courseID: "courseID",
-            screen: .courseDashbaord
+            screen: .courseDashbaord,
+            onDismiss: {}
         )
     }
 }
diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift
index 5179e7873..b26a0a3c8 100644
--- a/Course/Course/Presentation/Outline/CourseOutlineView.swift
+++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift
@@ -83,12 +83,16 @@ public struct CourseOutlineView: View {
                                    courseDeadlineInfo.datesBannerInfo.status == .resetDatesBanner,
                                    !courseDeadlineInfo.hasEnded,
                                    !isVideo,
-                                   !viewModel.isShowProgress {
+                                   !viewModel.isShowProgress,
+                                   viewModel.canShowBanner {
                                     DatesStatusInfoView(
                                         datesBannerInfo: courseDeadlineInfo.datesBannerInfo,
                                         courseID: courseID,
                                         courseContainerViewModel: viewModel,
-                                        screen: .courseDashbaord
+                                        screen: .courseDashbaord,
+                                        onDismiss: {
+                                            viewModel.dismissBanner(forCourse: courseID)
+                                        }
                                     )
                                     .padding(.horizontal, 16)
                                     .padding(.top, 16)
@@ -218,6 +222,8 @@ public struct CourseOutlineView: View {
             .paymentSnackbar()
         }
         .onAppear {
+            viewModel.updateBannerVisibilityStatus(forCourse: courseID)
+
             Task {
                await viewModel.updateCourseIfNeeded(courseID: courseID)
             }
diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift
index d1e40846d..2a364f2d1 100644
--- a/Course/Course/SwiftGen/Strings.swift
+++ b/Course/Course/SwiftGen/Strings.swift
@@ -176,24 +176,24 @@ public enum CourseLocalization {
       public enum TabInfoBanner {
         /// We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track.
         public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY", fallback: "We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track.")
-        /// 
-        public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER", fallback: "")
+        /// Learn at your own pace
+        public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER", fallback: "Learn at your own pace")
       }
       public enum UpgradeToCompleteGradedBanner {
         /// To complete graded assignments as part of this course, you can upgrade today.
         public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY", fallback: "To complete graded assignments as part of this course, you can upgrade today.")
         /// 
         public static let button = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON", fallback: "")
-        /// 
-        public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER", fallback: "")
+        /// Unlock graded assignments
+        public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER", fallback: "Unlock graded assignments")
       }
       public enum UpgradeToResetBanner {
-        /// You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.
-        public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY", fallback: "You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.")
+        /// You're auditing this course and can't submit graded assignments. To access grading and shift missed deadlines, upgrade now.
+        public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY", fallback: "You're auditing this course and can't submit graded assignments. To access grading and shift missed deadlines, upgrade now.")
         /// 
         public static let button = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON", fallback: "")
-        /// 
-        public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER", fallback: "")
+        /// Upgrade for full access
+        public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER", fallback: "Upgrade for full access")
       }
     }
   }
diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings
index 0ffba5977..2d1058de1 100644
--- a/Course/Course/en.lproj/Localizable.strings
+++ b/Course/Course/en.lproj/Localizable.strings
@@ -112,15 +112,15 @@
 "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER" = "Missed some deadlines?";
 
 "COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY" = "We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track.";
-"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = "";
+"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = "Learn at your own pace";
 
 "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY" = "To complete graded assignments as part of this course, you can upgrade today.";
 "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON" = "";
-"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER" = "";
+"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER" = "Unlock graded assignments";
 
-"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY" = "You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.";
+"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY" = "You're auditing this course and can't submit graded assignments. To access grading and shift missed deadlines, upgrade now.";
 "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON" = "";
-"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = "";
+"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = "Upgrade for full access";
 
 "COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Your dates could not be shifted. Please try again.";
 "COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Your dates have been successfully shifted.";
diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings
index c0598519e..7bccb35b0 100644
--- a/Course/Course/uk.lproj/Localizable.strings
+++ b/Course/Course/uk.lproj/Localizable.strings
@@ -111,15 +111,15 @@
 "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER" = "Missed some deadlines?";
 
 "COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY" = "We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track.";
-"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = "";
+"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = "Learn at your own pace";
 
 "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY" = "To complete graded assignments as part of this course, you can upgrade today.";
 "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON" = "";
-"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER" = "";
+"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER" = "Unlock graded assignments";
 
-"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY" = "You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.";
+"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY" = "You're auditing this course and can't submit graded assignments. To access grading and shift missed deadlines, upgrade now.";
 "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON" = "";
-"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = "";
+"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = "Upgrade for full access";
 
 "COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Your dates could not be shifted. Please try again.";
 "COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Your dates have been successfully shifted.";
diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift
index b15b4740e..509e65e02 100644
--- a/Course/CourseTests/CourseMock.generated.swift
+++ b/Course/CourseTests/CourseMock.generated.swift
@@ -2900,6 +2900,26 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock {
 		}
     }
 
+    open func canShowBanner(_ bannerType: CourseBannerType?, forCourse courseID: String) -> Bool {
+        addInvocation(.m_canShowBanner__bannerTypeforCourse_courseID(Parameter<CourseBannerType?>.value(`bannerType`), Parameter<String>.value(`courseID`)))
+		let perform = methodPerformValue(.m_canShowBanner__bannerTypeforCourse_courseID(Parameter<CourseBannerType?>.value(`bannerType`), Parameter<String>.value(`courseID`))) as? (CourseBannerType?, String) -> Void
+		perform?(`bannerType`, `courseID`)
+		var __value: Bool
+		do {
+		    __value = try methodReturnValue(.m_canShowBanner__bannerTypeforCourse_courseID(Parameter<CourseBannerType?>.value(`bannerType`), Parameter<String>.value(`courseID`))).casted()
+		} catch {
+			onFatalFailure("Stub return value not specified for canShowBanner(_ bannerType: CourseBannerType?, forCourse courseID: String). Use given")
+			Failure("Stub return value not specified for canShowBanner(_ bannerType: CourseBannerType?, forCourse courseID: String). Use given")
+		}
+		return __value
+    }
+
+    open func markBannerDismissed(_ bannerType: CourseBannerType, forCourse courseID: String) {
+        addInvocation(.m_markBannerDismissed__bannerTypeforCourse_courseID(Parameter<CourseBannerType>.value(`bannerType`), Parameter<String>.value(`courseID`)))
+		let perform = methodPerformValue(.m_markBannerDismissed__bannerTypeforCourse_courseID(Parameter<CourseBannerType>.value(`bannerType`), Parameter<String>.value(`courseID`))) as? (CourseBannerType, String) -> Void
+		perform?(`bannerType`, `courseID`)
+    }
+
 
     fileprivate enum MethodType {
         case m_getCourseBlocks__courseID_courseID(Parameter<String>)
@@ -2913,6 +2933,8 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock {
         case m_getCourseDates__courseID_courseID(Parameter<String>)
         case m_getCourseDeadlineInfo__courseID_courseID(Parameter<String>)
         case m_shiftDueDates__courseID_courseID(Parameter<String>)
+        case m_canShowBanner__bannerTypeforCourse_courseID(Parameter<CourseBannerType?>, Parameter<String>)
+        case m_markBannerDismissed__bannerTypeforCourse_courseID(Parameter<CourseBannerType>, Parameter<String>)
 
         static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult {
             switch (lhs, rhs) {
@@ -2972,6 +2994,18 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock {
 				var results: [Matcher.ParameterComparisonResult] = []
 				results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID"))
 				return Matcher.ComparisonResult(results)
+
+            case (.m_canShowBanner__bannerTypeforCourse_courseID(let lhsBannertype, let lhsCourseid), .m_canShowBanner__bannerTypeforCourse_courseID(let rhsBannertype, let rhsCourseid)):
+				var results: [Matcher.ParameterComparisonResult] = []
+				results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBannertype, rhs: rhsBannertype, with: matcher), lhsBannertype, rhsBannertype, "_ bannerType"))
+				results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "forCourse courseID"))
+				return Matcher.ComparisonResult(results)
+
+            case (.m_markBannerDismissed__bannerTypeforCourse_courseID(let lhsBannertype, let lhsCourseid), .m_markBannerDismissed__bannerTypeforCourse_courseID(let rhsBannertype, let rhsCourseid)):
+				var results: [Matcher.ParameterComparisonResult] = []
+				results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBannertype, rhs: rhsBannertype, with: matcher), lhsBannertype, rhsBannertype, "_ bannerType"))
+				results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "forCourse courseID"))
+				return Matcher.ComparisonResult(results)
             default: return .none
             }
         }
@@ -2989,6 +3023,8 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock {
             case let .m_getCourseDates__courseID_courseID(p0): return p0.intValue
             case let .m_getCourseDeadlineInfo__courseID_courseID(p0): return p0.intValue
             case let .m_shiftDueDates__courseID_courseID(p0): return p0.intValue
+            case let .m_canShowBanner__bannerTypeforCourse_courseID(p0, p1): return p0.intValue + p1.intValue
+            case let .m_markBannerDismissed__bannerTypeforCourse_courseID(p0, p1): return p0.intValue + p1.intValue
             }
         }
         func assertionName() -> String {
@@ -3004,6 +3040,8 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock {
             case .m_getCourseDates__courseID_courseID: return ".getCourseDates(courseID:)"
             case .m_getCourseDeadlineInfo__courseID_courseID: return ".getCourseDeadlineInfo(courseID:)"
             case .m_shiftDueDates__courseID_courseID: return ".shiftDueDates(courseID:)"
+            case .m_canShowBanner__bannerTypeforCourse_courseID: return ".canShowBanner(_:forCourse:)"
+            case .m_markBannerDismissed__bannerTypeforCourse_courseID: return ".markBannerDismissed(_:forCourse:)"
             }
         }
     }
@@ -3044,6 +3082,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock {
         public static func getCourseDeadlineInfo(courseID: Parameter<String>, willReturn: CourseDateBanner...) -> MethodStub {
             return Given(method: .m_getCourseDeadlineInfo__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) }))
         }
+        public static func canShowBanner(_ bannerType: Parameter<CourseBannerType?>, forCourse courseID: Parameter<String>, willReturn: Bool...) -> MethodStub {
+            return Given(method: .m_canShowBanner__bannerTypeforCourse_courseID(`bannerType`, `courseID`), products: willReturn.map({ StubProduct.return($0 as Any) }))
+        }
         public static func getCourseVideoBlocks(fullStructure: Parameter<CourseStructure>, willProduce: (Stubber<CourseStructure>) -> Void) -> MethodStub {
             let willReturn: [CourseStructure] = []
 			let given: Given = { return Given(method: .m_getCourseVideoBlocks__fullStructure_fullStructure(`fullStructure`), products: willReturn.map({ StubProduct.return($0 as Any) })) }()
@@ -3051,6 +3092,13 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock {
 			willProduce(stubber)
 			return given
         }
+        public static func canShowBanner(_ bannerType: Parameter<CourseBannerType?>, forCourse courseID: Parameter<String>, willProduce: (Stubber<Bool>) -> Void) -> MethodStub {
+            let willReturn: [Bool] = []
+			let given: Given = { return Given(method: .m_canShowBanner__bannerTypeforCourse_courseID(`bannerType`, `courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }()
+			let stubber = given.stub(for: (Bool).self)
+			willProduce(stubber)
+			return given
+        }
         public static func getCourseBlocks(courseID: Parameter<String>, willThrow: Error...) -> MethodStub {
             return Given(method: .m_getCourseBlocks__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) }))
         }
@@ -3167,6 +3215,8 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock {
         public static func getCourseDates(courseID: Parameter<String>) -> Verify { return Verify(method: .m_getCourseDates__courseID_courseID(`courseID`))}
         public static func getCourseDeadlineInfo(courseID: Parameter<String>) -> Verify { return Verify(method: .m_getCourseDeadlineInfo__courseID_courseID(`courseID`))}
         public static func shiftDueDates(courseID: Parameter<String>) -> Verify { return Verify(method: .m_shiftDueDates__courseID_courseID(`courseID`))}
+        public static func canShowBanner(_ bannerType: Parameter<CourseBannerType?>, forCourse courseID: Parameter<String>) -> Verify { return Verify(method: .m_canShowBanner__bannerTypeforCourse_courseID(`bannerType`, `courseID`))}
+        public static func markBannerDismissed(_ bannerType: Parameter<CourseBannerType>, forCourse courseID: Parameter<String>) -> Verify { return Verify(method: .m_markBannerDismissed__bannerTypeforCourse_courseID(`bannerType`, `courseID`))}
     }
 
     public struct Perform {
@@ -3206,6 +3256,12 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock {
         public static func shiftDueDates(courseID: Parameter<String>, perform: @escaping (String) -> Void) -> Perform {
             return Perform(method: .m_shiftDueDates__courseID_courseID(`courseID`), performs: perform)
         }
+        public static func canShowBanner(_ bannerType: Parameter<CourseBannerType?>, forCourse courseID: Parameter<String>, perform: @escaping (CourseBannerType?, String) -> Void) -> Perform {
+            return Perform(method: .m_canShowBanner__bannerTypeforCourse_courseID(`bannerType`, `courseID`), performs: perform)
+        }
+        public static func markBannerDismissed(_ bannerType: Parameter<CourseBannerType>, forCourse courseID: Parameter<String>, perform: @escaping (CourseBannerType, String) -> Void) -> Perform {
+            return Perform(method: .m_markBannerDismissed__bannerTypeforCourse_courseID(`bannerType`, `courseID`), performs: perform)
+        }
     }
 
     public func given(_ method: Given) {
diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift
index 9c3eba0f5..0b8595e72 100644
--- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift
+++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift
@@ -53,7 +53,8 @@ final class CourseDateViewModelTests: XCTestCase {
             courseProgress: nil,
             lmsPrice: .zero
         )
-        
+
+        Given(interactor, .canShowBanner(.any, forCourse: .any, willReturn: true))
         Given(interactor, .getCourseDates(courseID: .any, willReturn: courseDates))
         Given(interactor, .getLoadedCourseBlocks(courseID: .any, willReturn: courseStructure))
         
@@ -84,7 +85,8 @@ final class CourseDateViewModelTests: XCTestCase {
         let cssInjector = CSSInjectorMock()
         let connectivity = ConnectivityProtocolMock()
         let config = ConfigMock()
-        
+
+        Given(interactor, .canShowBanner(.any, forCourse: .any, willReturn: true))
         Given(interactor, .getCourseDates(courseID: .any, willThrow: NSError(domain: "error", code: -1, userInfo: nil)))
         
         let viewModel = CourseDatesViewModel(
@@ -114,7 +116,8 @@ final class CourseDateViewModelTests: XCTestCase {
         let config = ConfigMock()
         
         let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet))
-        
+
+        Given(interactor, .canShowBanner(.any, forCourse: .any, willReturn: true))
         Given(interactor, .getCourseDates(courseID: .any, willThrow: noInternetError))
         
         let viewModel = CourseDatesViewModel(
diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift
index a906c2932..f7726fe04 100644
--- a/OpenEdX/DI/ScreenAssembly.swift
+++ b/OpenEdX/DI/ScreenAssembly.swift
@@ -323,7 +323,8 @@ class ScreenAssembly: Assembly {
         }
         container.register(CourseInteractorProtocol.self) { r in
             CourseInteractor(
-                repository: r.resolve(CourseRepositoryProtocol.self)!
+                repository: r.resolve(CourseRepositoryProtocol.self)!,
+                storage: r.resolve(CourseStorage.self)!
             )
         }
         container.register(CourseDetailsViewModel.self) { r in
diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift
index f9f0cf7c6..8ce89f99c 100644
--- a/OpenEdX/Data/AppStorage.swift
+++ b/OpenEdX/Data/AppStorage.swift
@@ -224,7 +224,29 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto
             }
         }
     }
-    
+
+    public func dismissalDate(for bannerType: CourseBannerType, courseID: String) -> Date? {
+        let key = makeKey(bannerType: bannerType, courseID: courseID)
+        guard let dateString = userDefaults.string(forKey: key) else {
+            return nil
+        }
+
+        return Date(iso8601: dateString)
+    }
+
+    public func setDismissalDate(for bannerType: CourseBannerType, courseID: String, to date: Date?) {
+        let key = makeKey(bannerType: bannerType, courseID: courseID)
+
+        if let date {
+            userDefaults.set(
+                date.dateToString(style: .iso8601),
+                forKey: key
+            )
+        } else {
+            userDefaults.removeObject(forKey: key)
+        }
+    }
+
     public var resetAppSupportDirectoryUserData: Bool? {
         get {
             return userDefaults.bool(forKey: KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA)
@@ -323,9 +345,14 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto
     private let KEY_APPLE_SIGN_FULLNAME = "appleSignFullName"
     private let KEY_APPLE_SIGN_EMAIL = "appleSignEmail"
     private let KEY_ALLOWED_DOWNLOAD_LARGE_FILE = "allowedDownloadLargeFile"
+    private let KEY_PREFIX_COURSE_BANNER_DISMISSAL_DATE = "courseBannerDismissalDate"
     private let KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA = "resetAppSupportDirectoryUserData"
     private let KEY_LAST_USED_SOCIAL_AUTH = "lastUsedSocialAuth"
     private let KEY_DISCUSSION_NOTIFICATIONS_SETTING_STATUS = "discussionNotificationsSettingStatus"
     private let KEY_NOTIFICATIONS_PRIMER_DISMISSAL_COUNT = "notificationsPrimerDismissalCount"
     private let KEY_NOTIFICATIONS_PRIMER_LAST_SHOWN_DATE = "notificationsPrimerLastShownDate"
+
+    private func makeKey(bannerType: CourseBannerType, courseID: String) -> String {
+        return "\(KEY_PREFIX_COURSE_BANNER_DISMISSAL_DATE).\(bannerType.rawValue).\(courseID)"
+    }
 }


Reviews:
user: login: rnr
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-30T15:48:30Z
user: login: mta452
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-02T07:44:36Z
user: login: rnr
body: LGTM
state: APPROVED
author_association: NONE
submitted_at: 2025-05-02T09:56:07Z
user: login: shafqat-muneer
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-05-07T10:19:55Z
user: login: mta452
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-13T09:16:22Z
user: login: mta452
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-13T09:16:41Z
user: login: shafqat-muneer
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-13T12:17:54Z
user: login: mta452
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-13T12:46:14Z
user: login: shafqat-muneer
body: LGTM 👍 
state: APPROVED
author_association: NONE
submitted_at: 2025-05-14T07:27:51Z

Review comments:
diff_hunk: @@ -278,6 +279,10 @@ public class CourseContainerViewModel: BaseCourseViewModel {
         do {
             let courseDeadlineInfo = try await interactor.getCourseDeadlineInfo(courseID: courseID)
             withAnimation {
+                self.canShowBanner = interactor.canShowBanner(
path: Course/Course/Presentation/Container/CourseContainerViewModel.swift
user: login: rnr
body: Using a `withAnimation` block inside a ViewModel is bad practice (I realize you didn't add it, but let's try to avoid it) - it can lead to UI problems and violates the principle that a ViewModel shouldn't handle UI logic, but is responsible for business logic and states
created_at: 2025-04-30T15:32:01Z
author_association: NONE
diff_hunk: @@ -278,6 +279,10 @@ public class CourseContainerViewModel: BaseCourseViewModel {
         do {
             let courseDeadlineInfo = try await interactor.getCourseDeadlineInfo(courseID: courseID)
             withAnimation {
+                self.canShowBanner = interactor.canShowBanner(
path: Course/Course/Presentation/Container/CourseContainerViewModel.swift
user: login: mta452
body: Agreed, I'll move the assignment outside of animation block. But I'm not sure about the next line: `self.courseDeadlineInfo = courseDeadlineInfo`. It will require some careful analysis and refactoring, so keeping it.
created_at: 2025-05-02T07:44:36Z
author_association: NONE
diff_hunk: @@ -211,14 +211,26 @@ struct CourseDateListView: View {
                         if !courseDates.hasEnded {
                             CalendarSyncView(courseID: courseID, viewModel: viewModel)
                                 .padding(.bottom, 16)
-                            
-                            DatesStatusInfoView(
-                                datesBannerInfo: courseDates.datesBannerInfo,
-                                courseID: courseID,
-                                courseDatesViewModel: viewModel,
-                                screen: .courseDates
-                            )
-                            .padding(.bottom, 16)
+
+                            if viewModel.canShowBanner {
+                                DatesStatusInfoView(
+                                    datesBannerInfo: courseDates.datesBannerInfo,
+                                    courseID: courseID,
+                                    courseDatesViewModel: viewModel,
+                                    screen: .courseDates,
+                                    onDismiss: {
+                                        viewModel.dismissBanner(forCourse: courseID)
+                                        viewModel.trackPLSEvent(
+                                            .plsBannerDismissed,
+                                            bivalue: .plsBannerDismissed,
+                                            courseID: courseID,
+                                            screenName: DatesStatusInfoScreen.courseDates.rawValue,
+                                            type: courseDates.datesBannerInfo.status?.analyticsBannerType ?? ""
+                                        )
path: Course/Course/Presentation/Dates/CourseDatesView.swift
user: login: shafqat-muneer
body: This analytics is already present under call of `viewModel.dismissBanner(forCourse: courseID)`. I think we can safely remove it.
created_at: 2025-05-07T08:25:17Z
author_association: NONE
diff_hunk: @@ -107,6 +108,14 @@ public class CourseDatesViewModel: ObservableObject {
     @MainActor
     func getCourseDates(courseID: String) async {
         isShowProgress = true
+
+        defer {
+            canShowBanner = interactor.canShowBanner(
+                courseDates?.datesBannerInfo.status?.storageBannerType,
+                forCourse: courseID
+            )
+        }
+
path: Course/Course/Presentation/Dates/CourseDatesViewModel.swift
user: login: shafqat-muneer
body: Let's also move `isShowProgress = false` into `defer` block and remove from `do {} catch {}` blocks.
created_at: 2025-05-07T09:50:53Z
author_association: NONE
diff_hunk: @@ -211,14 +211,26 @@ struct CourseDateListView: View {
                         if !courseDates.hasEnded {
                             CalendarSyncView(courseID: courseID, viewModel: viewModel)
                                 .padding(.bottom, 16)
-                            
-                            DatesStatusInfoView(
-                                datesBannerInfo: courseDates.datesBannerInfo,
-                                courseID: courseID,
-                                courseDatesViewModel: viewModel,
-                                screen: .courseDates
-                            )
-                            .padding(.bottom, 16)
+
+                            if viewModel.canShowBanner {
+                                DatesStatusInfoView(
+                                    datesBannerInfo: courseDates.datesBannerInfo,
+                                    courseID: courseID,
+                                    courseDatesViewModel: viewModel,
+                                    screen: .courseDates,
+                                    onDismiss: {
+                                        viewModel.dismissBanner(forCourse: courseID)
+                                        viewModel.trackPLSEvent(
+                                            .plsBannerDismissed,
+                                            bivalue: .plsBannerDismissed,
+                                            courseID: courseID,
+                                            screenName: DatesStatusInfoScreen.courseDates.rawValue,
+                                            type: courseDates.datesBannerInfo.status?.analyticsBannerType ?? ""
+                                        )
path: Course/Course/Presentation/Dates/CourseDatesView.swift
user: login: mta452
body: Done.
created_at: 2025-05-13T09:16:22Z
author_association: NONE
diff_hunk: @@ -107,6 +108,14 @@ public class CourseDatesViewModel: ObservableObject {
     @MainActor
     func getCourseDates(courseID: String) async {
         isShowProgress = true
+
+        defer {
+            canShowBanner = interactor.canShowBanner(
+                courseDates?.datesBannerInfo.status?.storageBannerType,
+                forCourse: courseID
+            )
+        }
+
path: Course/Course/Presentation/Dates/CourseDatesViewModel.swift
user: login: mta452
body: Which do catch block?
created_at: 2025-05-13T09:16:41Z
author_association: NONE
diff_hunk: @@ -107,6 +108,14 @@ public class CourseDatesViewModel: ObservableObject {
     @MainActor
     func getCourseDates(courseID: String) async {
         isShowProgress = true
+
+        defer {
+            canShowBanner = interactor.canShowBanner(
+                courseDates?.datesBannerInfo.status?.storageBannerType,
+                forCourse: courseID
+            )
+        }
+
path: Course/Course/Presentation/Dates/CourseDatesViewModel.swift
user: login: shafqat-muneer
body: From line `119 to 132`
created_at: 2025-05-13T12:17:54Z
author_association: NONE
diff_hunk: @@ -107,6 +108,14 @@ public class CourseDatesViewModel: ObservableObject {
     @MainActor
     func getCourseDates(courseID: String) async {
         isShowProgress = true
+
+        defer {
+            canShowBanner = interactor.canShowBanner(
+                courseDates?.datesBannerInfo.status?.storageBannerType,
+                forCourse: courseID
+            )
+        }
+
path: Course/Course/Presentation/Dates/CourseDatesViewModel.swift
user: login: mta452
body: Ah, I see. They weren't visible on the home page. I don't think the block should be removed as there's `try await interactor.getCourseDates(courseID: courseID)` in one of the lines.
created_at: 2025-05-13T12:46:13Z
author_association: NONE

PR comments:
user: login: shafqat-muneer
created_at: 2025-05-07T15:48:42Z
body: @mta452 The `Shift Due Dates` banner appears under both the `Home` and `Dates` titles. If the user taps the close (X) button on the banner in either section, it should disappear from both, just like it does after tapping on `Shift Due Dates` button.

@omerhabib26 What's the behavior on Android side. Kindly confirm. Thanks
author_association: NONE
user: login: omerhabib26
created_at: 2025-05-07T16:20:35Z
body: Yes @shafqat-muneer , If user tap on dismiss icon on any screen, it will disappear from both screens.
author_association: NONE
user: login: mta452
created_at: 2025-05-13T14:48:22Z
body: @shafqat-muneer Please have a second round of review.
author_association: NONE