diff --git a/.gitignore b/.gitignore index 75a5357e4..1bf01033f 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,6 @@ I18N/ *.lproj/ !en.lproj/ /config_script/__pycache__ + +# AI Assistant Configuration +CLAUDE.md diff --git a/AppDates/AppDates.xcodeproj/project.pbxproj b/AppDates/AppDates.xcodeproj/project.pbxproj index 88e73cb76..bae152567 100644 --- a/AppDates/AppDates.xcodeproj/project.pbxproj +++ b/AppDates/AppDates.xcodeproj/project.pbxproj @@ -544,7 +544,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -583,7 +583,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -737,7 +737,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = stepanok.com.AppDatesTests; @@ -761,7 +761,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = stepanok.com.AppDatesTests; @@ -860,7 +860,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -890,7 +890,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = stepanok.com.AppDatesTests; @@ -989,7 +989,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1019,7 +1019,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = stepanok.com.AppDatesTests; @@ -1118,7 +1118,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1148,7 +1148,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = stepanok.com.AppDatesTests; @@ -1240,7 +1240,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1269,7 +1269,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = stepanok.com.AppDatesTests; @@ -1361,7 +1361,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1390,7 +1390,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = stepanok.com.AppDatesTests; @@ -1482,7 +1482,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1511,7 +1511,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = stepanok.com.AppDatesTests; diff --git a/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift b/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift index 07e731e7a..1042e2911 100644 --- a/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift +++ b/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift @@ -2065,9 +2065,10 @@ public final class DatesRepositoryProtocolMock: DatesRepositoryProtocol, @unchec public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Sendable { public init() { } - public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false) { + public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false, internetState: InternetState? = nil) { self.isInternetAvaliable = isInternetAvaliable self.isMobileData = isMobileData + self.internetState = internetState } @@ -2083,6 +2084,9 @@ public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Se get { return _internetReachableSubject } set { _internetReachableSubject = newValue } } + + + public var internetState: InternetState? = nil } public final class DownloadManagerProtocolMock: DownloadManagerProtocol, @unchecked Sendable { diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index 08d312520..cc7f6c2e9 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -694,7 +694,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -722,7 +722,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -805,7 +805,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -832,7 +832,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -850,7 +850,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -868,7 +868,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -886,7 +886,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -904,7 +904,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -922,7 +922,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -940,7 +940,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1029,7 +1029,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1122,7 +1122,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1220,7 +1220,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1313,7 +1313,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1469,7 +1469,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1504,7 +1504,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index f92309117..aeb19f3e4 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -18,7 +18,6 @@ public struct SignInView: View { @Environment(\.isHorizontal) private var isHorizontal - @ObservedObject private var viewModel: SignInViewModel public init(viewModel: SignInViewModel) { diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 14bd27a92..659d1b2c8 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -16,11 +16,11 @@ import GoogleSignIn import MSAL @MainActor -public class SignInViewModel: ObservableObject { +@Observable public class SignInViewModel { - @Published private(set) var isShowProgress = false - @Published private(set) var showError: Bool = false - @Published private(set) var showAlert: Bool = false + private(set) var isShowProgress = false + private(set) var showError: Bool = false + private(set) var showAlert: Bool = false let sourceScreen: LogistrationSourceScreen var errorMessage: String? { diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 4bf20dda2..801750243 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -11,20 +11,13 @@ import OEXFoundation import Theme public struct SignUpView: View { - - @State - private var disclosureGroupOpen: Bool = false - + @Environment(\.isHorizontal) private var isHorizontal - - @ObservedObject - private var viewModel: SignUpViewModel - + + @Bindable private var viewModel: SignUpViewModel + public init(viewModel: SignUpViewModel) { self.viewModel = viewModel - Task { - await viewModel.getRegistrationFields() - } } public var body: some View { @@ -115,7 +108,7 @@ public struct SignUpView: View { ) if !viewModel.isShowProgress { - DisclosureGroup(isExpanded: $disclosureGroupOpen) { + DisclosureGroup(isExpanded: $viewModel.disclosureGroupOpen) { FieldsView( fields: optionalFields, router: viewModel.router, @@ -125,7 +118,7 @@ public struct SignUpView: View { ) .padding(.horizontal, 1) } label: { - Text(disclosureGroupOpen + Text(viewModel.disclosureGroupOpen ? AuthLocalization.SignUp.hideFields : AuthLocalization.SignUp.showFields) .font(Theme.Fonts.labelLarge) @@ -200,6 +193,9 @@ public struct SignUpView: View { .ignoresSafeArea(.all, edges: .horizontal) .background(Theme.Colors.background.ignoresSafeArea(.all)) .navigationBarHidden(true) + .task { + await viewModel.getRegistrationFields() + } .onFirstAppear { viewModel.trackScreenEvent() } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index 88af34779..f22c157b7 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -15,14 +15,24 @@ import GoogleSignIn import MSAL @MainActor -public final class SignUpViewModel: ObservableObject { - - @Published var isShowProgress = false - @Published var scrollTo: Int? - @Published var showError: Bool = false - @Published var thirdPartyAuthSuccess: Bool = false +@Observable public final class SignUpViewModel { + + private let interactor: AuthInteractorProtocol + private let analytics: AuthorizationAnalytics + private let validator: Validator + + let router: AuthorizationRouter + let config: ConfigProtocol + let cssInjector: CSSInjector let sourceScreen: LogistrationSourceScreen - + let storage: CoreStorage + + var isShowProgress = false + var scrollTo: Int? + var showError: Bool = false + var thirdPartyAuthSuccess: Bool = false + var disclosureGroupOpen = false + var errorMessage: String? { didSet { withAnimation { @@ -31,7 +41,7 @@ public final class SignUpViewModel: ObservableObject { } } - @Published var fields: [FieldConfiguration] = [] + var fields: [FieldConfiguration] = [] var requiredFields: [FieldConfiguration] { fields.filter { $0.field.required && @@ -49,15 +59,7 @@ public final class SignUpViewModel: ObservableObject { fields.filter { !$0.field.required } } - let router: AuthorizationRouter - let config: ConfigProtocol - let cssInjector: CSSInjector - - private let interactor: AuthInteractorProtocol - private let analytics: AuthorizationAnalytics - private let validator: Validator var authMethod: AuthMethod = .password - let storage: CoreStorage public init( interactor: AuthInteractorProtocol, diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index e069b75e1..8775d21d3 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -12,15 +12,10 @@ import Theme public struct ResetPasswordView: View { - @State private var email: String = "" - - @State private var isRecovered: Bool = false - @Environment(\.isHorizontal) private var isHorizontal - - @ObservedObject - private var viewModel: ResetPasswordViewModel - + + @Bindable private var viewModel: ResetPasswordViewModel + public init(viewModel: ResetPasswordViewModel) { self.viewModel = viewModel } @@ -46,7 +41,7 @@ public struct ResetPasswordView: View { ScrollView { VStack { - if isRecovered { + if viewModel.isRecovered { ZStack { VStack { CoreAssets.checkEmail.swiftUIImage @@ -62,7 +57,7 @@ public struct ResetPasswordView: View { .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 4) .accessibilityIdentifier("recover_title_text") - Text(AuthLocalization.Forgot.checkDescription + email) + Text(AuthLocalization.Forgot.checkDescription + viewModel.email) .font(Theme.Fonts.bodyMedium) .multilineTextAlignment(.center) .foregroundColor(Theme.Colors.textPrimary) @@ -93,7 +88,7 @@ public struct ResetPasswordView: View { .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("email_text") - TextField("", text: $email) + TextField("", text: $viewModel.email) .font(Theme.Fonts.bodyLarge) .foregroundColor(Theme.Colors.textInputTextColor) .keyboardType(.emailAddress) @@ -104,7 +99,7 @@ public struct ResetPasswordView: View { .background( Theme.InputFieldBackground( placeHolder: AuthLocalization.SignIn.email, - text: email, + text: viewModel.email, padding: 15 ) ) @@ -123,7 +118,8 @@ public struct ResetPasswordView: View { } else { StyledButton(AuthLocalization.Forgot.request) { Task { - await viewModel.resetPassword(email: email, isRecovered: $isRecovered) + await viewModel.resetPassword(email: viewModel.email, + isRecovered: $viewModel.isRecovered) } } .padding(.top, 30) diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift index c58c593e4..0b5ddf040 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift @@ -10,11 +10,15 @@ import Core import OEXFoundation @MainActor -public final class ResetPasswordViewModel: ObservableObject { - - @Published private(set) var isShowProgress = false - @Published private(set) var showError: Bool = false - @Published private(set) var showAlert: Bool = false +@Observable public final class ResetPasswordViewModel { + + private(set) var isShowProgress = false + private(set) var showError: Bool = false + private(set) var showAlert: Bool = false + + var email: String = "" + var isRecovered: Bool = false + var errorMessage: String? { didSet { withAnimation { diff --git a/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift b/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift index a1bd57936..07ab806e2 100644 --- a/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift +++ b/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift @@ -16,11 +16,12 @@ import GoogleSignIn import MSAL @MainActor -public class SSOWebViewModel: ObservableObject { +@Observable +public class SSOWebViewModel { - @Published private(set) var isShowProgress = false - @Published private(set) var showError: Bool = false - @Published private(set) var showAlert: Bool = false + private(set) var isShowProgress = false + private(set) var showError: Bool = false + private(set) var showAlert: Bool = false let sourceScreen: LogistrationSourceScreen = .default var errorMessage: String? { diff --git a/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift b/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift index 6fda03cb4..857c44c9e 100644 --- a/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift +++ b/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift @@ -12,13 +12,13 @@ import Theme struct SocialAuthView: View { // MARK: - Properties - @StateObject var viewModel: SocialAuthViewModel + var viewModel: SocialAuthViewModel init( authType: SocialAuthType = .signIn, viewModel: SocialAuthViewModel ) { - self._viewModel = .init(wrappedValue: viewModel) + self.viewModel = viewModel self.authType = authType } diff --git a/Authorization/Authorization/Presentation/SocialAuth/SocialAuthViewModel.swift b/Authorization/Authorization/Presentation/SocialAuth/SocialAuthViewModel.swift index b4163a588..d93114287 100644 --- a/Authorization/Authorization/Presentation/SocialAuth/SocialAuthViewModel.swift +++ b/Authorization/Authorization/Presentation/SocialAuth/SocialAuthViewModel.swift @@ -57,14 +57,15 @@ enum SocialAuthDetails { } @MainActor -final public class SocialAuthViewModel: ObservableObject { +@Observable +final public class SocialAuthViewModel { // MARK: - Properties private var completion: ((Result) -> Void) private let config: ConfigProtocol - @Published var lastUsedOption: SocialAuthMethod? + var lastUsedOption: SocialAuthMethod? var enabledOptions: [SocialAuthMethod] = [] init( @@ -81,10 +82,10 @@ final public class SocialAuthViewModel: ObservableObject { configureEnabledOptions() } - private lazy var appleAuthProvider: AppleAuthProvider = .init(config: config) - private lazy var googleAuthProvider: GoogleAuthProvider = .init() - private lazy var facebookAuthProvider: FacebookAuthProvider = .init() - private lazy var microsoftAuthProvider: MicrosoftAuthProvider = .init() + @ObservationIgnored private lazy var appleAuthProvider: AppleAuthProvider = .init(config: config) + @ObservationIgnored private lazy var googleAuthProvider: GoogleAuthProvider = .init() + @ObservationIgnored private lazy var facebookAuthProvider: FacebookAuthProvider = .init() + @ObservationIgnored private lazy var microsoftAuthProvider: MicrosoftAuthProvider = .init() private var topViewController: UIViewController? { UIApplication.topViewController() diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index 32c73b673..7becf99b6 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -12,13 +12,12 @@ import Theme public struct StartupView: View { - @State private var searchQuery: String = "" +// @State private var searchQuery: String = "" @Environment(\.isHorizontal) private var isHorizontal - - @ObservedObject - private var viewModel: StartupViewModel - + + @Bindable private var viewModel: StartupViewModel + public init(viewModel: StartupViewModel) { self.viewModel = viewModel } @@ -56,13 +55,13 @@ public struct StartupView: View { .padding(.leading, 16) .padding(.top, 1) .foregroundColor(Theme.Colors.textInputTextColor) - TextField("", text: $searchQuery, onCommit: { - if searchQuery.isEmpty { return } + TextField("", text: $viewModel.searchQuery, onCommit: { + if viewModel.searchQuery.isEmpty { return } viewModel.router.showDiscoveryScreen( - searchQuery: searchQuery, + searchQuery: viewModel.searchQuery, sourceScreen: .startup ) - viewModel.logAnalytics(searchQuery: searchQuery) + viewModel.logAnalytics(searchQuery: viewModel.searchQuery) }) .autocapitalization(.none) .autocorrectionDisabled() @@ -80,14 +79,14 @@ public struct StartupView: View { .background( Theme.InputFieldBackground( placeHolder: AuthLocalization.Startup.searchPlaceholder, - text: searchQuery, + text: viewModel.searchQuery, padding: 48 ) ) Button { viewModel.router.showDiscoveryScreen( - searchQuery: searchQuery, + searchQuery: viewModel.searchQuery, sourceScreen: .startup ) viewModel.logAnalytics() @@ -120,7 +119,7 @@ public struct StartupView: View { .padding(.bottom, 2) } .onDisappear { - searchQuery = "" + viewModel.searchQuery = "" } .frameLimit() } diff --git a/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift index 68691ff69..1a5835acb 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift @@ -9,12 +9,12 @@ import Foundation import Core @MainActor -public class StartupViewModel: ObservableObject { +@Observable public class StartupViewModel { let router: AuthorizationRouter let analytics: CoreAnalytics let config: ConfigProtocol - @Published var searchQuery: String? + var searchQuery: String = "" public init( router: AuthorizationRouter, diff --git a/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift b/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift index 9d91b858d..bf2c1a9ae 100644 --- a/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift +++ b/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift @@ -1760,9 +1760,10 @@ public final class AuthorizationRouterMock: AuthorizationRouter, @unchecked Send public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Sendable { public init() { } - public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false) { + public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false, internetState: InternetState? = nil) { self.isInternetAvaliable = isInternetAvaliable self.isMobileData = isMobileData + self.internetState = internetState } @@ -1778,6 +1779,9 @@ public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Se get { return _internetReachableSubject } set { _internetReachableSubject = newValue } } + + + public var internetState: InternetState? = nil } public final class AuthorizationAnalyticsMock: AuthorizationAnalytics { diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 0b33f2da7..150c7377c 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -48,7 +48,6 @@ 027BD3AD2909475000392132 /* KeyboardScroller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3AA2909474F00392132 /* KeyboardScroller.swift */; }; 027BD3AE2909475000392132 /* KeyboardScrollerOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3AB2909474F00392132 /* KeyboardScrollerOptions.swift */; }; 027BD3AF2909475000392132 /* DismissKeyboardTapHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3AC2909475000392132 /* DismissKeyboardTapHandler.swift */; }; - 027BD3B32909475900392132 /* Publishers+KeyboardState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3B02909475800392132 /* Publishers+KeyboardState.swift */; }; 027BD3B42909475900392132 /* KeyboardState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3B12909475800392132 /* KeyboardState.swift */; }; 027BD3B52909475900392132 /* KeyboardStateObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3B22909475900392132 /* KeyboardStateObserver.swift */; }; 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3B62909476200392132 /* DismissKeyboardTapViewModifier.swift */; }; @@ -250,7 +249,6 @@ 027BD3AA2909474F00392132 /* KeyboardScroller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardScroller.swift; sourceTree = ""; }; 027BD3AB2909474F00392132 /* KeyboardScrollerOptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardScrollerOptions.swift; sourceTree = ""; }; 027BD3AC2909475000392132 /* DismissKeyboardTapHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DismissKeyboardTapHandler.swift; sourceTree = ""; }; - 027BD3B02909475800392132 /* Publishers+KeyboardState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Publishers+KeyboardState.swift"; sourceTree = ""; }; 027BD3B12909475800392132 /* KeyboardState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardState.swift; sourceTree = ""; }; 027BD3B22909475900392132 /* KeyboardStateObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardStateObserver.swift; sourceTree = ""; }; 027BD3B62909476200392132 /* DismissKeyboardTapViewModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DismissKeyboardTapViewModifier.swift; sourceTree = ""; }; @@ -478,7 +476,6 @@ children = ( 027BD3B12909475800392132 /* KeyboardState.swift */, 027BD3B22909475900392132 /* KeyboardStateObserver.swift */, - 027BD3B02909475800392132 /* Publishers+KeyboardState.swift */, ); path = State; sourceTree = ""; @@ -1064,7 +1061,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - CE953A3D2CD0DA940023D669 /* Generate Mockolo Mocks */ = { + 0770DE5A28D0B1E5006D8A5D /* SwiftGen */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -1074,16 +1071,16 @@ ); inputPaths = ( ); - name = "Generate Mockolo Mocks"; + name = SwiftGen; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if which mockolo >/dev/null; then\n mockolo \\\n --sourcedirs \"${SRCROOT}/Core\" \\\n --destination \"${SRCROOT}/CoreTests/Generated/CoreMocks.generated.swift\" \\\n --mock-final \\\n --testable-imports \"Core\" \\\n --custom-imports \"Foundation\" \"SwiftUI\" \"Combine\"\nelse\n echo \"warning: mockolo not installed, download from https://github.com/uber/mockolo\"\nfi\n"; + shellScript = "if [[ -f \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" ]]; then\n \"${PODS_ROOT}/SwiftGen/bin/swiftgen\"\nelse\n echo \"warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it.\"\nfi\n"; }; - 0770DE5A28D0B1E5006D8A5D /* SwiftGen */ = { + CE953A3D2CD0DA940023D669 /* Generate Mockolo Mocks */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -1093,14 +1090,14 @@ ); inputPaths = ( ); - name = SwiftGen; + name = "Generate Mockolo Mocks"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [[ -f \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" ]]; then\n \"${PODS_ROOT}/SwiftGen/bin/swiftgen\"\nelse\n echo \"warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it.\"\nfi\n"; + shellScript = "if which mockolo >/dev/null; then\n mockolo \\\n --sourcedirs \"${SRCROOT}/Core\" \\\n --destination \"${SRCROOT}/CoreTests/Generated/CoreMocks.generated.swift\" \\\n --mock-final \\\n --testable-imports \"Core\" \\\n --custom-imports \"Foundation\" \"SwiftUI\" \"Combine\"\nelse\n echo \"warning: mockolo not installed, download from https://github.com/uber/mockolo\"\nfi\n"; }; ED83AD5255805030E042D62A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -1191,7 +1188,6 @@ 06619EAF2B973B25001FAADE /* AccessibilityInjection.swift in Sources */, BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */, 02935B732BCECAD000B22F66 /* Data_PrimaryEnrollment.swift in Sources */, - 027BD3B32909475900392132 /* Publishers+KeyboardState.swift in Sources */, 06DEA4A32BBD66A700110D20 /* BackNavigationButton.swift in Sources */, 0727877D28D25212002E9142 /* ProgressBar.swift in Sources */, CE09B2B62CE796AE0090DB53 /* InvalidCoreDataContextError.swift in Sources */, @@ -1423,7 +1419,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1453,7 +1449,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1543,7 +1539,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1572,7 +1568,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1594,7 +1590,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1617,7 +1613,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1640,7 +1636,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1663,7 +1659,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1685,7 +1681,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1707,7 +1703,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1802,7 +1798,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1899,7 +1895,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2001,7 +1997,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2098,7 +2094,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2259,7 +2255,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2297,7 +2293,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Core/Core/AvoidingHelpers/Avoider/KeyboardAvoidingViewController.swift b/Core/Core/AvoidingHelpers/Avoider/KeyboardAvoidingViewController.swift index 2ed85d65c..3b4170549 100644 --- a/Core/Core/AvoidingHelpers/Avoider/KeyboardAvoidingViewController.swift +++ b/Core/Core/AvoidingHelpers/Avoider/KeyboardAvoidingViewController.swift @@ -1,6 +1,5 @@ // -import Combine import SwiftUI import UIKit @@ -12,7 +11,7 @@ final class KeyboardAvoidingViewController: UIViewController { private var hostingController: UIHostingController private var bottomConstraint: NSLayoutConstraint? - private var keyboardStateCancellable: AnyCancellable? + @MainActor private var keyboardStateObserver: KeyboardStateObserver? private var keyboardState: KeyboardState = .default var bottomPadding: CGFloat = 0 { @@ -77,10 +76,25 @@ final class KeyboardAvoidingViewController: UIViewController { } private func subscribeToKeyboardPublisher() { - keyboardStateCancellable = Publishers.keyboardStatePublisher - .sink { [weak self] state in - self?.updateBottomConstraint(state: state) + Task { @MainActor [weak self] in + guard let self else { return } + + if self.keyboardStateObserver == nil { + self.keyboardStateObserver = KeyboardStateObserver() } + + guard let observer = self.keyboardStateObserver else { return } + + withObservationTracking { + _ = observer.keyboardState + } onChange: { + Task { @MainActor in + guard let observer = self.keyboardStateObserver else { return } + self.updateBottomConstraint(state: observer.keyboardState) + self.subscribeToKeyboardPublisher() + } + } + } } private func updateBottomConstraint(state: KeyboardState) { diff --git a/Core/Core/AvoidingHelpers/State/KeyboardState.swift b/Core/Core/AvoidingHelpers/State/KeyboardState.swift index 005ada41a..cf1f334b0 100644 --- a/Core/Core/AvoidingHelpers/State/KeyboardState.swift +++ b/Core/Core/AvoidingHelpers/State/KeyboardState.swift @@ -3,7 +3,17 @@ import SwiftUI import UIKit -public struct KeyboardState: Sendable { +struct NotificationData: @unchecked Sendable { + let name: Notification.Name + let userInfo: [AnyHashable: Any]? + + init(from notification: Notification) { + self.name = notification.name + self.userInfo = notification.userInfo + } +} + +public struct KeyboardState: Sendable, Equatable { public let animationDuration: TimeInterval /// Keyboard notification return a private curve value - 7. @@ -25,7 +35,6 @@ public struct KeyboardState: Sendable { // MARK: - Static -@MainActor extension KeyboardState { static let `default` = KeyboardState( animationDuration: 0, @@ -33,6 +42,7 @@ extension KeyboardState { frame: .zero ) + @MainActor static func from(notification: Notification) -> KeyboardState? { return from( notification: notification, @@ -40,6 +50,7 @@ extension KeyboardState { ) } + @MainActor static func from( notification: Notification, screen: UIScreen @@ -61,6 +72,26 @@ extension KeyboardState { frame: frame ) } + + @MainActor + static func from(notificationData: NotificationData) -> KeyboardState? { + guard + expectedNotificationNames.contains(notificationData.name), + let userInfo = notificationData.userInfo else { + return nil + } + + let animationDuration = Self.animationDuration(from: userInfo) + let animationCurve = Self.animationCurve(from: userInfo) + + let frame = Self.keyboardFrame(from: userInfo, screen: .main) + + return KeyboardState( + animationDuration: animationDuration, + animationCurve: animationCurve, + frame: frame + ) + } private static var expectedNotificationNames: [Notification.Name] { [ @@ -87,6 +118,7 @@ extension KeyboardState { return curveValue } + @MainActor private static func keyboardFrame( from userInfo: [AnyHashable: Any], screen: UIScreen diff --git a/Core/Core/AvoidingHelpers/State/KeyboardStateObserver.swift b/Core/Core/AvoidingHelpers/State/KeyboardStateObserver.swift index 8a1f094a4..949e700ae 100644 --- a/Core/Core/AvoidingHelpers/State/KeyboardStateObserver.swift +++ b/Core/Core/AvoidingHelpers/State/KeyboardStateObserver.swift @@ -1,17 +1,77 @@ // -import Combine +import SwiftUI @MainActor -final class KeyboardStateObserver: ObservableObject { - @Published private(set) var keyboardState: KeyboardState = .default +@Observable +final class KeyboardStateObserver { + private(set) var keyboardState: KeyboardState = .default - private var subscription: AnyCancellable? + nonisolated(unsafe) private var observers: [NSObjectProtocol] = [] init() { - subscription = Publishers.keyboardStatePublisher - .sink(receiveValue: { [weak self] state in - self?.keyboardState = state - }) + let notificationCenter = NotificationCenter.default + + // Observe keyboard will show + let showObserver = notificationCenter.addObserver( + forName: UIResponder.keyboardWillShowNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self = self else { return } + + let notificationData = NotificationData(from: notification) + + Task { @MainActor in + if let state = KeyboardState.from(notificationData: notificationData) { + self.updateState(state) + } + } + } + + let changeObserver = notificationCenter.addObserver( + forName: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self = self else { return } + + // Extract notification data in nonisolated context + let notificationData = NotificationData(from: notification) + + Task { @MainActor in + if let state = KeyboardState.from(notificationData: notificationData) { + self.updateState(state) + } + } + } + + let hideObserver = notificationCenter.addObserver( + forName: UIResponder.keyboardWillHideNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self = self else { return } + + let notificationData = NotificationData(from: notification) + + Task { @MainActor in + if let state = KeyboardState.from(notificationData: notificationData) { + self.updateState(state) + } + } + } + + observers = [showObserver, changeObserver, hideObserver] + } + + private func updateState(_ newState: KeyboardState) { + // Remove duplicates (same as removeDuplicates in Combine) + guard newState.height != keyboardState.height else { return } + keyboardState = newState + } + + deinit { + observers.forEach { NotificationCenter.default.removeObserver($0) } } } diff --git a/Core/Core/AvoidingHelpers/State/Publishers+KeyboardState.swift b/Core/Core/AvoidingHelpers/State/Publishers+KeyboardState.swift deleted file mode 100644 index e11689050..000000000 --- a/Core/Core/AvoidingHelpers/State/Publishers+KeyboardState.swift +++ /dev/null @@ -1,34 +0,0 @@ -// - -import Combine -import SwiftUI - -@MainActor -public extension Publishers { - static var keyboardStatePublisher: AnyPublisher { - let notificationCenter: NotificationCenter = .default - - let keyboardWillHide: NotificationCenter.Publisher = - notificationCenter.publisher(for: UIResponder.keyboardWillHideNotification) - - let keyboardWillChangeFrame: NotificationCenter.Publisher = - notificationCenter.publisher(for: UIResponder.keyboardWillChangeFrameNotification) - - let keyboardWillShow: NotificationCenter.Publisher = - notificationCenter.publisher(for: UIResponder.keyboardWillShowNotification) - - return Publishers.MergeMany( - keyboardWillHide, - keyboardWillChangeFrame, - keyboardWillShow - ) - .map { notification -> KeyboardState? in - KeyboardState.from(notification: notification) - } - .replaceNil(with: .default) - .removeDuplicates(by: { lhs, rhs -> Bool in - lhs.height == rhs.height - }) - .eraseToAnyPublisher() - } -} diff --git a/Core/Core/AvoidingHelpers/ViewModifiers/KeyboardAvoidingModifier.swift b/Core/Core/AvoidingHelpers/ViewModifiers/KeyboardAvoidingModifier.swift index 4a4879bb3..eb56ce2cc 100644 --- a/Core/Core/AvoidingHelpers/ViewModifiers/KeyboardAvoidingModifier.swift +++ b/Core/Core/AvoidingHelpers/ViewModifiers/KeyboardAvoidingModifier.swift @@ -1,13 +1,14 @@ // -import Combine import SwiftUI -public class KeyboardScrollInvocator: ObservableObject { - var triggerSubject = PassthroughSubject() - +@MainActor +@Observable +public class KeyboardScrollInvocator { + var onTrigger: (() -> Void)? + public func scrollToActiveInput() { - triggerSubject.send(true) + onTrigger?() } } @@ -16,9 +17,9 @@ private struct KeyboardAvoidingModifier: ViewModifier { private let partialAvoidingPadding: CGFloat private let dismissKeyboardByTap: Bool private let onProvideScrollInvocator: ((KeyboardScrollInvocator) -> Void)? - - @StateObject private var keyboardObserver = KeyboardStateObserver() - @StateObject private var scrollInvocator = KeyboardScrollInvocator() + + @State private var keyboardObserver = KeyboardStateObserver() + @State private var scrollInvocator = KeyboardScrollInvocator() init( scrollerOptions: KeyboardScrollerOptions?, @@ -38,19 +39,19 @@ private struct KeyboardAvoidingModifier: ViewModifier { .ignoresSafeArea(.keyboard, edges: .bottom) } .ignoresSafeArea(.keyboard, edges: .bottom) - + // for fields - .onReceive(keyboardObserver.$keyboardState.receive(on: DispatchQueue.main)) { state in + .onChange(of: keyboardObserver.keyboardState) { _, state in if state.height == 0 { DismissKeyboardTapHandler.shared.isEnabled = false return } - + // Applied to the whole UIWindow. Use addTapToEndEditing() modifier to apply locally. if dismissKeyboardByTap { DismissKeyboardTapHandler.shared.isEnabled = true } - + if let options = scrollerOptions { KeyboardScroller.scroll( keyboardState: state, @@ -59,19 +60,21 @@ private struct KeyboardAvoidingModifier: ViewModifier { ) } } - .onReceive(scrollInvocator.triggerSubject) { _ in - guard !keyboardObserver.keyboardState.height.isZero, - let options = scrollerOptions else { - return - } - - KeyboardScroller.scroll( - keyboardState: keyboardObserver.keyboardState, - options: options, - partialAvoidingPadding: partialAvoidingPadding - ) - } .onAppear { + // Setup callback for manual scroll triggering + scrollInvocator.onTrigger = { [weak keyboardObserver, scrollerOptions, partialAvoidingPadding] in + guard let keyboardObserver, + !keyboardObserver.keyboardState.height.isZero, + let options = scrollerOptions else { + return + } + + KeyboardScroller.scroll( + keyboardState: keyboardObserver.keyboardState, + options: options, + partialAvoidingPadding: partialAvoidingPadding + ) + } onProvideScrollInvocator?(scrollInvocator) } } diff --git a/Core/Core/Configuration/Combine/Debounce.swift b/Core/Core/Configuration/Combine/Debounce.swift index c14510842..2de271f0a 100644 --- a/Core/Core/Configuration/Combine/Debounce.swift +++ b/Core/Core/Configuration/Combine/Debounce.swift @@ -11,21 +11,23 @@ import Combine public struct Debounce { public let scheduler: S public let dueTime: S.SchedulerTimeType.Stride + public let dueTimeInMilliseconds: Int - public init(scheduler: S, dueTime: S.SchedulerTimeType.Stride) { + public init(scheduler: S, dueTime: S.SchedulerTimeType.Stride, dueTimeInMilliseconds: Int) { self.scheduler = scheduler self.dueTime = dueTime + self.dueTimeInMilliseconds = dueTimeInMilliseconds } } public extension Debounce where S == RunLoop { static var searchDebounce: Debounce { - Debounce(scheduler: RunLoop.main, dueTime: .milliseconds(800)) + Debounce(scheduler: RunLoop.main, dueTime: .milliseconds(800), dueTimeInMilliseconds: 800) } } public extension Debounce where S == ImmediateScheduler { static var test: Debounce { - Debounce(scheduler: ImmediateScheduler.shared, dueTime: .zero) + Debounce(scheduler: ImmediateScheduler.shared, dueTime: .zero, dueTimeInMilliseconds: 0) } } diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 0b5c57fdb..0f57ed721 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -20,8 +20,11 @@ public protocol ConnectivityProtocol: Sendable { var isInternetAvaliable: Bool { get } var isMobileData: Bool { get } var internetReachableSubject: CurrentValueSubject { get } + var internetState: InternetState? { get } } +@MainActor +@Observable public class Connectivity: ConnectivityProtocol { private let networkManager = NetworkReachabilityManager() @@ -32,12 +35,21 @@ public class Connectivity: ConnectivityProtocol { private var lastVerificationDate: TimeInterval? private var lastVerificationResult: Bool = true + // MARK: - Observable property (new way) + public private(set) var internetState: InternetState? { + didSet { + // Keep backward compatibility - update Combine subject + internetReachableSubject.send(internetState) + } + } + + // MARK: - Combine subject (for backward compatibility) public let internetReachableSubject = CurrentValueSubject(nil) private(set) var _isInternetAvailable: Bool = true { didSet { Task { @MainActor in - internetReachableSubject.send(_isInternetAvailable ? .reachable : .notReachable) + internetState = _isInternetAvailable ? .reachable : .notReachable } } } diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index 5e8dc6416..66bc6a8fb 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -25,4 +25,5 @@ public extension Notification.Name { static let refreshEnrollments = Notification.Name("refreshEnrollments") static let onVideoProgressUpdated = Notification.Name("onVideoProgressUpdated") static let onAssignmentProgressUpdated = Notification.Name("onAssignmentProgressUpdated") + static let saveVideoProgressBeforeNavigation = Notification.Name("saveVideoProgressBeforeNavigation") } diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index e02ba47ab..219b5e4f0 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -247,21 +247,7 @@ public actor DownloadManager: DownloadManagerProtocol, @unchecked Sendable { } private func addObsevers() async { - await connectivity.internetReachableSubject - .sink {[weak self] state in - guard let self else { return } - Task { - switch state { - case .notReachable: - await self.waitingAll() - case .reachable: - try? await self.resumeDownloading() - case .none: - return - } - } - } - .store(in: &cancellables) + observeConnectivity() NotificationCenter.default.publisher(for: .tryDownloadAgain) .compactMap { $0.object as? [DownloadDataTask] } @@ -273,6 +259,26 @@ public actor DownloadManager: DownloadManagerProtocol, @unchecked Sendable { .store(in: &cancellables) } + nonisolated private func observeConnectivity() { + Task { @MainActor [connectivity] in + withObservationTracking { + _ = connectivity.internetState + } onChange: { + Task { [connectivity] in + switch await connectivity.internetState { + case .notReachable: + await self.waitingAll() + case .reachable: + try? await self.resumeDownloading() + case .none: + break + } + self.observeConnectivity() + } + } + } + } + private func tryDownloadAgain(downloads: [DownloadDataTask]) async { var tasksToInsert: [DownloadDataTask] = [] diff --git a/Core/Core/Network/OfflineSyncManager.swift b/Core/Core/Network/OfflineSyncManager.swift index bb4d995f5..1718cf68a 100644 --- a/Core/Core/Network/OfflineSyncManager.swift +++ b/Core/Core/Network/OfflineSyncManager.swift @@ -7,7 +7,6 @@ import Foundation @preconcurrency import WebKit -@preconcurrency import Combine import Swinject import OEXFoundation @@ -22,7 +21,6 @@ public class OfflineSyncManager: OfflineSyncManagerProtocol { let persistence: CorePersistenceProtocol let interactor: OfflineSyncInteractorProtocol let connectivity: ConnectivityProtocol - private var cancellables = Set() public init( persistence: CorePersistenceProtocol, @@ -33,16 +31,26 @@ public class OfflineSyncManager: OfflineSyncManagerProtocol { self.interactor = interactor self.connectivity = connectivity - self.connectivity.internetReachableSubject.sink(receiveValue: { state in - switch state { - case .reachable: - Task(priority: .low) { + observeConnectivity() + } + + private func observeConnectivity() { + withObservationTracking { + _ = connectivity.internetState + } onChange: { + Task { @MainActor [weak self] in + guard let self else { return } + + switch self.connectivity.internetState { + case .reachable: await self.syncOfflineProgress() + case .notReachable, nil: + break } - case .notReachable, nil: - break + + self.observeConnectivity() } - }).store(in: &cancellables) + } } public func handleMessage(message: WKScriptMessage, blockID: String) async { diff --git a/Core/Core/View/Base/AppReview/AppReviewView.swift b/Core/Core/View/Base/AppReview/AppReviewView.swift index efe5df5bd..550e0a00f 100644 --- a/Core/Core/View/Base/AppReview/AppReviewView.swift +++ b/Core/Core/View/Base/AppReview/AppReviewView.swift @@ -11,7 +11,7 @@ import Theme public struct AppReviewView: View { - @ObservedObject private var viewModel: AppReviewViewModel + @Bindable private var viewModel: AppReviewViewModel @Environment(\.isHorizontal) private var isHorizontal @Environment(\.presentationMode) private var presentationMode diff --git a/Core/Core/View/Base/AppReview/AppReviewViewModel.swift b/Core/Core/View/Base/AppReview/AppReviewViewModel.swift index 4b9f66c64..7de5cc800 100644 --- a/Core/Core/View/Base/AppReview/AppReviewViewModel.swift +++ b/Core/Core/View/Base/AppReview/AppReviewViewModel.swift @@ -9,7 +9,8 @@ import SwiftUI import StoreKit @MainActor -public class AppReviewViewModel: ObservableObject { +@Observable +public class AppReviewViewModel { enum ReviewState { case vote @@ -42,12 +43,12 @@ public class AppReviewViewModel: ObservableObject { } } - @Published var state: ReviewState = .vote - @Published var rating: Int = 0 - @Published var showReview: Bool = false - @Published var showSelectMailClientView: Bool = false - @Published var feedback: String = "" - @Published var clients: [ThirdPartyMailClient] = [] + var state: ReviewState = .vote + var rating: Int = 0 + var showReview: Bool = false + var showSelectMailClientView: Bool = false + var feedback: String = "" + var clients: [ThirdPartyMailClient] = [] let allClients = ThirdPartyMailClient.clients private let config: ConfigProtocol diff --git a/Core/Core/View/Base/BackNavigationButton.swift b/Core/Core/View/Base/BackNavigationButton.swift index 415433cd1..d88782b90 100644 --- a/Core/Core/View/Base/BackNavigationButton.swift +++ b/Core/Core/View/Base/BackNavigationButton.swift @@ -15,7 +15,7 @@ class BackButton: UIButton { } public struct BackNavigationButtonRepresentable: UIViewRepresentable { - @ObservedObject var viewModel: BackNavigationButtonViewModel + var viewModel: BackNavigationButtonViewModel var action: (() -> Void)? var color: Color @@ -64,7 +64,7 @@ public struct BackNavigationButtonRepresentable: UIViewRepresentable { } public struct BackNavigationButton: View { - @StateObject var viewModel = BackNavigationButtonViewModel() + var viewModel = BackNavigationButtonViewModel() private let color: Color private let action: (() -> Void)? diff --git a/Core/Core/View/Base/BackNavigationButtonViewModel.swift b/Core/Core/View/Base/BackNavigationButtonViewModel.swift index cce158e2c..9993bc784 100644 --- a/Core/Core/View/Base/BackNavigationButtonViewModel.swift +++ b/Core/Core/View/Base/BackNavigationButtonViewModel.swift @@ -26,9 +26,10 @@ public struct BackNavigationMenuItem: Identifiable { } @MainActor -class BackNavigationButtonViewModel: ObservableObject { +@Observable +class BackNavigationButtonViewModel { private let helper: BackNavigationProtocol - @Published var items: [BackNavigationMenuItem] = [] + var items: [BackNavigationMenuItem] = [] init() { self.helper = Container.shared.resolve(BackNavigationProtocol.self)! diff --git a/Core/Core/View/Base/FieldConfiguration.swift b/Core/Core/View/Base/FieldConfiguration.swift index 63ce055e7..175bc8219 100644 --- a/Core/Core/View/Base/FieldConfiguration.swift +++ b/Core/Core/View/Base/FieldConfiguration.swift @@ -8,23 +8,24 @@ import Foundation import SwiftUI -public class FieldConfiguration: ObservableObject { - @Published public var shake: Bool = false - @Published public var error: String { +@Observable +public class FieldConfiguration { + public var shake: Bool = false + public var error: String { didSet { if error.count > 0 { shake = true } } } - @Published public var text: String { + public var text: String { didSet { error = "" shake = false } } - @Published public var selectedItem: PickerItem? + public var selectedItem: PickerItem? public let field: PickerFields public init(error: String = "", text: String = "", field: PickerFields, selectedItem: PickerItem? = nil) { diff --git a/Core/Core/View/Base/FileWebView.swift b/Core/Core/View/Base/FileWebView.swift index b6b98f4e5..3537fd789 100644 --- a/Core/Core/View/Base/FileWebView.swift +++ b/Core/Core/View/Base/FileWebView.swift @@ -47,17 +47,18 @@ public struct FileWebView: UIViewRepresentable { public func updateUIView(_ webview: WKWebView, context: Context) { } - - public class ViewModel: ObservableObject { + + @Observable + public class ViewModel { - @Published var url: String + var url: String public init(url: String) { self.url = url } } - @ObservedObject var viewModel: ViewModel + var viewModel: ViewModel public init(viewModel: ViewModel) { self.viewModel = viewModel diff --git a/Core/Core/View/Base/OfflineSnackBarView.swift b/Core/Core/View/Base/OfflineSnackBarView.swift index ad6c70130..e6af859d8 100644 --- a/Core/Core/View/Base/OfflineSnackBarView.swift +++ b/Core/Core/View/Base/OfflineSnackBarView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import Combine import Theme public struct OfflineSnackBarView: View { @@ -62,18 +61,16 @@ public struct OfflineSnackBarView: View { .offset(y: dismiss ? 100 : 0) .opacity(dismiss ? 0 : 1) .transition(.move(edge: .bottom)) - .onReceive(connectivity.internetReachableSubject, perform: { state in + .onChange(of: connectivity.internetState) { _, state in switch state { case .notReachable: withAnimation { dismiss = false } - case .reachable: - break - case .none: + case .reachable, .none: break } - }) + } } } diff --git a/Core/Core/View/Base/PickerView.swift b/Core/Core/View/Base/PickerView.swift index e0403b822..9cac4f0e4 100644 --- a/Core/Core/View/Base/PickerView.swift +++ b/Core/Core/View/Base/PickerView.swift @@ -9,9 +9,8 @@ import SwiftUI import Theme public struct PickerView: View { - - @ObservedObject - private var config: FieldConfiguration + + @Bindable private var config: FieldConfiguration private var router: BaseRouter public init(config: FieldConfiguration, router: BaseRouter) { diff --git a/Core/Core/View/Base/RegistrationTextField.swift b/Core/Core/View/Base/RegistrationTextField.swift index 83cb3eb24..ffec300fd 100644 --- a/Core/Core/View/Base/RegistrationTextField.swift +++ b/Core/Core/View/Base/RegistrationTextField.swift @@ -10,16 +10,14 @@ import Theme public struct RegistrationTextField: View { - @State public var shakeIt: Bool = false @State public var placeholder: String = "" public var keyboardType: UIKeyboardType public var textContentType: UITextContentType private var isTextArea: Bool private var scrollTo: (() -> Void) = {} - @ObservedObject - private var config: FieldConfiguration - + @Bindable private var config: FieldConfiguration + public init(config: FieldConfiguration, isTextArea: Bool = false, keyboardType: UIKeyboardType = .default, diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 2fd29240a..576e5d9da 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -9,12 +9,13 @@ import SwiftUI import Kingfisher import Theme -public final class VideoDownloadQualityViewModel: ObservableObject { +@Observable +public final class VideoDownloadQualityViewModel { var didSelect: ((DownloadQuality) -> Void)? let downloadQuality = DownloadQuality.allCases - @Published var selectedDownloadQuality: DownloadQuality { + var selectedDownloadQuality: DownloadQuality { willSet { if newValue != selectedDownloadQuality { didSelect?(newValue) @@ -30,7 +31,6 @@ public final class VideoDownloadQualityViewModel: ObservableObject { public struct VideoDownloadQualityView: View { - @StateObject private var viewModel: VideoDownloadQualityViewModel private var analytics: CoreAnalytics private var router: BaseRouter @@ -44,11 +44,9 @@ public struct VideoDownloadQualityView: View { router: BaseRouter, isModal: Bool = false ) { - self._viewModel = StateObject( - wrappedValue: .init( - downloadQuality: downloadQuality, - didSelect: didSelect - ) + self.viewModel = VideoDownloadQualityViewModel( + downloadQuality: downloadQuality, + didSelect: didSelect ) self.analytics = analytics self.router = router diff --git a/Core/Core/View/Base/WebUnitView.swift b/Core/Core/View/Base/WebUnitView.swift index bb143560f..06f935ef9 100644 --- a/Core/Core/View/Base/WebUnitView.swift +++ b/Core/Core/View/Base/WebUnitView.swift @@ -11,7 +11,7 @@ import Theme public struct WebUnitView: View { - @StateObject private var viewModel: WebUnitViewModel + @Bindable private var viewModel: WebUnitViewModel @State private var isWebViewLoading = false private var url: String @@ -30,9 +30,7 @@ public struct WebUnitView: View { injections: [WebviewInjection]?, blockID: String ) { - self._viewModel = .init( - wrappedValue: viewModel - ) + self.viewModel = viewModel self.url = url self.dataUrl = dataUrl self.connectivity = connectivity diff --git a/Core/Core/View/Base/WebUnitViewModel.swift b/Core/Core/View/Base/WebUnitViewModel.swift index 693a2e4b6..8cd41688d 100644 --- a/Core/Core/View/Base/WebUnitViewModel.swift +++ b/Core/Core/View/Base/WebUnitViewModel.swift @@ -8,17 +8,19 @@ import Foundation import SwiftUI -public final class WebUnitViewModel: ObservableObject, WebviewCookiesUpdateProtocol { - +@MainActor +@Observable +public final class WebUnitViewModel: WebviewCookiesUpdateProtocol { + public let authInteractor: AuthInteractorProtocol let config: ConfigProtocol let syncManager: OfflineSyncManagerProtocol - - @Published public var updatingCookies: Bool = false - @Published public var cookiesReady: Bool = false - @Published public var showError: Bool = false + + public var updatingCookies: Bool = false + public var cookiesReady: Bool = false + public var showError: Bool = false private var retryCount = 1 - + public var errorMessage: String? { didSet { withAnimation { @@ -26,7 +28,7 @@ public final class WebUnitViewModel: ObservableObject, WebviewCookiesUpdateProto } } } - + public init( authInteractor: AuthInteractorProtocol, config: ConfigProtocol, diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index bfd43a9f9..2dc0e737d 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -24,9 +24,10 @@ public protocol WebViewNavigationDelegate: AnyObject { public struct WebView: UIViewRepresentable { - public class ViewModel: ObservableObject { - - @Published var url: String + @Observable + public class ViewModel { + + var url: String let baseURL: String let injections: [WebviewInjection]? var openFile: (String) -> Void @@ -44,7 +45,7 @@ public struct WebView: UIViewRepresentable { } } - @ObservedObject var viewModel: ViewModel + var viewModel: ViewModel @Binding public var isLoading: Bool var webViewNavDelegate: WebViewNavigationDelegate? let connectivity: ConnectivityProtocol diff --git a/Core/CoreTests/Generated/CoreMocks.generated.swift b/Core/CoreTests/Generated/CoreMocks.generated.swift index 45a37fe58..96d0533ea 100644 --- a/Core/CoreTests/Generated/CoreMocks.generated.swift +++ b/Core/CoreTests/Generated/CoreMocks.generated.swift @@ -1412,9 +1412,10 @@ public final class CourseStructureManagerProtocolMock: CourseStructureManagerPro public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Sendable { public init() { } - public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false) { + public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false, internetState: InternetState? = nil) { self.isInternetAvaliable = isInternetAvaliable self.isMobileData = isMobileData + self.internetState = internetState } @@ -1430,6 +1431,9 @@ public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Se get { return _internetReachableSubject } set { _internetReachableSubject = newValue } } + + + public var internetState: InternetState? = nil } public final class DownloadManagerProtocolMock: DownloadManagerProtocol, @unchecked Sendable { diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index ce171507d..8c3a9be7f 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -1038,6 +1038,27 @@ shellPath = /bin/sh; shellScript = "if [[ -f \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" ]]; then\n \"${PODS_ROOT}/SwiftGen/bin/swiftgen\"\nelse\n echo \"warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it.\"\nfi\n"; }; + 92C3B3183886DDECE1CBAC22 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; B2836B9489F24D888A56D857 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1271,7 +1292,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1293,7 +1314,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1315,7 +1336,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1337,7 +1358,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1359,7 +1380,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1381,7 +1402,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1534,7 +1555,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1573,7 +1594,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1675,7 +1696,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1778,7 +1799,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1875,7 +1896,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1971,7 +1992,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2073,7 +2094,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2103,7 +2124,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -2192,7 +2213,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2221,7 +2242,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; diff --git a/Course/Course/Presentation/Container/BaseCourseViewModel.swift b/Course/Course/Presentation/Container/BaseCourseViewModel.swift index cd1df40c7..f0b67b68c 100644 --- a/Course/Course/Presentation/Container/BaseCourseViewModel.swift +++ b/Course/Course/Presentation/Container/BaseCourseViewModel.swift @@ -11,8 +11,8 @@ import Core import Combine @MainActor -open class BaseCourseViewModel: ObservableObject { - +@Observable open class BaseCourseViewModel { + let manager: DownloadManagerProtocol var cancellables = Set() diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 99d433bed..43f28ebfa 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -14,13 +14,12 @@ import Theme public struct CourseContainerView: View { - @ObservedObject - public var viewModel: CourseContainerViewModel - @ObservedObject + @Bindable public var viewModel: CourseContainerViewModel public var courseDatesViewModel: CourseDatesViewModel - @ObservedObject public var courseProgressViewModel: CourseProgressViewModel + @State private var isAnimatingForTap: Bool = false + @State private var discussionTopicsViewModel: DiscussionTopicsViewModel public var courseID: String private var title: String @State private var ignoreOffset: Bool = false @@ -31,6 +30,7 @@ public struct CourseContainerView: View { @Environment(\.isHorizontal) private var isHorizontal @Namespace private var animationNamespace private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + private let discussionRouter: DiscussionRouter private let coordinateBoundaryLower: CGFloat = -115 private let courseRawImage: String? @@ -60,6 +60,12 @@ public struct CourseContainerView: View { title: String, courseRawImage: String? ) { + let resolvedDiscussionTopicsViewModel = Container.shared.resolve( + DiscussionTopicsViewModel.self, + argument: title + )! + let resolvedDiscussionRouter = Container.shared.resolve(DiscussionRouter.self)! + self._discussionTopicsViewModel = State(initialValue: resolvedDiscussionTopicsViewModel) self.viewModel = viewModel self.courseDatesViewModel = courseDatesViewModel self.courseProgressViewModel = courseProgressViewModel @@ -76,6 +82,7 @@ public struct CourseContainerView: View { self.courseID = courseID self.title = title self.courseRawImage = courseRawImage + self.discussionRouter = resolvedDiscussionRouter } public var body: some View { @@ -281,9 +288,8 @@ public struct CourseContainerView: View { coordinate: $coordinate, collapsed: $collapsed, viewHeight: $viewHeight, - viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self, - argument: title)!, - router: Container.shared.resolve(DiscussionRouter.self)! + viewModel: discussionTopicsViewModel, + router: discussionRouter ) .tabItem { tab.image diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 397f0b346..1c5b40012 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -43,7 +43,7 @@ extension CourseTab { return CourseLocalization.CourseContainer.handouts } } - + public var image: Image { switch self { case .course: @@ -66,50 +66,49 @@ extension CourseTab { //swiftlint:disable type_body_length file_length @MainActor -public final class CourseContainerViewModel: BaseCourseViewModel { - - @Published public var selection: Int - @Published var selectedTab: ContentTab = .all - @Published var isShowProgress = false - @Published var isShowRefresh = false - @Published var courseStructure: CourseStructure? - @Published var courseDeadlineInfo: CourseDateBanner? - @Published var courseVideosStructure: CourseStructure? - @Published var courseAssignmentsStructure: CourseStructure? - @Published var courseProgressDetails: CourseProgressDetails? - @Published var showError: Bool = false - @Published var sequentialsDownloadState: [String: DownloadViewState] = [:] - @Published private(set) var downloadableVerticals: Set = [] - @Published var continueWith: ContinueWith? - @Published var userSettings: UserSettings? - @Published var isInternetAvaliable = true - @Published var dueDatesShifted: Bool = false - @Published var updateCourseProgress: Bool = false - @Published var totalFilesSize: Int = 1 - @Published var downloadedFilesSize: Int = 0 - @Published var largestDownloadBlocks: [CourseBlock] = [] - @Published var downloadAllButtonState: OfflineView.DownloadAllState = .start - @Published var expandedSections: [String: Bool] = [:] - @Published var courseDeadlines: CourseDates? - @Published private(set) var assignmentSectionsData: [AssignmentSection] = [] +@Observable public final class CourseContainerViewModel: BaseCourseViewModel { + + public var selection: Int + var selectedTab: ContentTab = .all + var isShowProgress = false + var isShowRefresh = false + var courseStructure: CourseStructure? + var courseDeadlineInfo: CourseDateBanner? + var courseVideosStructure: CourseStructure? + var courseAssignmentsStructure: CourseStructure? + var courseProgressDetails: CourseProgressDetails? +// var showError: Bool = false + var sequentialsDownloadState: [String: DownloadViewState] = [:] + private(set) var downloadableVerticals: Set = [] + var continueWith: ContinueWith? + var userSettings: UserSettings? + var isInternetAvaliable = true + var dueDatesShifted: Bool = false + var updateCourseProgress: Bool = false + var totalFilesSize: Int = 1 + var downloadedFilesSize: Int = 0 + var largestDownloadBlocks: [CourseBlock] = [] + var downloadAllButtonState: OfflineView.DownloadAllState = .start + var expandedSections: [String: Bool] = [:] + var courseDeadlines: CourseDates? + private(set) var assignmentSectionsData: [AssignmentSection] = [] private(set) var realDownloadedFilesSize: Int = 0 - - @Published var tabBarIndex = 0 - + private var isRefreshingVideoProgress = false + + var tabBarIndex = 0 + let completionPublisher = NotificationCenter.default.publisher(for: .onblockCompletionRequested) - - var errorMessage: String? { - didSet { - withAnimation { - showError = errorMessage != nil - } - } + + var errorMessage: String? + + var showError: Bool { + errorMessage != nil } - + let router: CourseRouter let config: ConfigProtocol let connectivity: ConnectivityProtocol - + let isActive: Bool? let courseStart: Date? let courseEnd: Date? @@ -120,17 +119,17 @@ public final class CourseContainerViewModel: BaseCourseViewModel { var courseDownloadTasks: [DownloadDataTask] = [] private(set) var waitingDownloads: [CourseBlock]? - + private let interactor: CourseInteractorProtocol private let authInteractor: AuthInteractorProtocol let analytics: CourseAnalytics let coreAnalytics: CoreAnalytics private(set) var storage: CourseStorage - + private let cellularFileSizeLimit: Int = 100 * 1024 * 1024 var courseHelper: CourseDownloadHelperProtocol - + public init( interactor: CourseInteractorProtocol, authInteractor: AuthInteractorProtocol, @@ -168,11 +167,11 @@ public final class CourseContainerViewModel: BaseCourseViewModel { self.coreAnalytics = coreAnalytics self.selection = selection.rawValue self.courseHelper = courseHelper - self.courseHelper.videoQuality = storage.userSettings?.downloadQuality ?? .auto super.init(manager: manager) + self.courseHelper.videoQuality = storage.userSettings?.downloadQuality ?? .auto addObservers() } - + func updateCourseIfNeeded(courseID: String) async { guard !isShowRefresh, !isShowProgress else { return @@ -185,25 +184,25 @@ public final class CourseContainerViewModel: BaseCourseViewModel { await getCourseBlocks(courseID: courseID, withProgress: true) } } - + func openLastVisitedBlock() { guard let continueWith = continueWith, let courseStructure = courseStructure else { return } let chapter = courseStructure.childs[continueWith.chapterIndex] let sequential = chapter.childs[continueWith.sequentialIndex] let continueUnit = sequential.childs[continueWith.verticalIndex] - + var continueBlock: CourseBlock? continueUnit.childs.forEach { block in if block.id == continueWith.lastVisitedBlockId { continueBlock = block } } - + trackResumeCourseClicked( blockId: continueBlock?.id ?? "" ) - + router.showCourseUnit( courseName: courseStructure.displayName, blockId: continueBlock?.id ?? "", @@ -225,14 +224,14 @@ public final class CourseContainerViewModel: BaseCourseViewModel { return try await interactor.getLoadedCourseBlocks(courseID: courseID) } } - + @MainActor func getCourseBlocks(courseID: String, withProgress: Bool = true) async { guard let courseStart, courseStart < Date() else { return } - + isShowProgress = withProgress isShowRefresh = !withProgress - + async let structureTask = getCourseStructure(courseID: courseID) async let progressTask: CourseProgressDetails? = { do { @@ -246,7 +245,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { return nil } }() - + do { guard let courseStructure = try await structureTask else { throw NSError( @@ -255,31 +254,32 @@ public final class CourseContainerViewModel: BaseCourseViewModel { userInfo: [NSLocalizedDescriptionKey: "Course structure is nil"] ) } - + self.courseStructure = courseStructure courseHelper.courseStructure = courseStructure + await courseHelper.refreshValue() update(from: courseHelper.value ?? .empty) - + // progress may still be downloading; assign when ready self.courseProgressDetails = await progressTask - + async let videosTask = interactor.getCourseVideoBlocks(fullStructure: courseStructure) async let assignmentsTask = interactor.getCourseAssignmentBlocks(fullStructure: courseStructure) - + courseVideosStructure = await videosTask courseAssignmentsStructure = await assignmentsTask updateAssignmentSections() - + if isInternetAvaliable { NotificationCenter.default.post(name: .getCourseDates, object: courseID) try? await getResumeBlock(courseID: courseID, courseStructure: courseStructure) } - + if expandedSections.isEmpty { initializeExpandedSections() } - + } catch { // Critical failure (no structure) — wipe everything debugLog("Failed to load course blocks: \(error.localizedDescription)") @@ -292,7 +292,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { isShowProgress = false isShowRefresh = false } - + @MainActor func getCourseDeadlineInfo(courseID: String, withProgress: Bool = true) async { do { @@ -304,18 +304,18 @@ public final class CourseContainerViewModel: BaseCourseViewModel { debugLog(error.localizedDescription) } } - + @MainActor func shiftDueDates(courseID: String, withProgress: Bool = true, screen: DatesStatusInfoScreen, type: String) async { isShowProgress = withProgress isShowRefresh = !withProgress - + do { try await interactor.shiftDueDates(courseID: courseID) NotificationCenter.default.post(name: .shiftCourseDates, object: courseID) isShowProgress = false isShowRefresh = false - + analytics.plsSuccessEvent( .plsShiftDatesSuccess, bivalue: .plsShiftDatesSuccess, @@ -324,7 +324,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { type: type, success: true ) - + } catch let error { isShowProgress = false isShowRefresh = false @@ -343,19 +343,19 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } } } - + func update(downloadQuality: DownloadQuality) { storage.userSettings?.downloadQuality = downloadQuality userSettings = storage.userSettings courseHelper.videoQuality = downloadQuality courseHelper.refreshValue() } - + @MainActor func tryToRefreshCookies() async { try? await authInteractor.getCookies(force: false) } - + @MainActor private func getResumeBlock(courseID: String, courseStructure: CourseStructure) async throws { if let lastVisitedBlockID { @@ -378,7 +378,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } } } - + @MainActor func onDownloadViewTap(chapter: CourseChapter, state: DownloadViewState) async { let blocks = chapter.childs @@ -406,7 +406,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { await download(state: state, blocks: blocks, sequentials: chapter.childs.filter({ $0.isDownloadable })) } - + func continueDownload() async { guard let blocks = waitingDownloads else { return @@ -419,7 +419,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } } } - + func trackSelectedTab( selection: CourseTab, courseId: String, @@ -442,7 +442,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { analytics.courseOutlineHandoutsTabClicked(courseId: courseId, courseName: courseName) } } - + func trackVerticalClicked( courseId: String, courseName: String, @@ -455,7 +455,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { blockName: vertical.displayName ) } - + func trackViewCertificateClicked(courseID: String) { analytics.trackCourseEvent( .courseViewCertificateClicked, @@ -463,7 +463,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { courseID: courseID ) } - + func trackSequentialClicked(_ sequential: CourseSequential) { guard let course = courseStructure else { return } analytics.sequentialClicked( @@ -473,7 +473,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { blockName: sequential.displayName ) } - + func trackSectionClicked(_ chapter: CourseChapter) { guard let course = courseStructure else { return } analytics.contentPageSectionClicked( @@ -483,7 +483,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { blockName: chapter.displayName ) } - + func trackShowCompletedSubsectionClicked() { guard let course = courseStructure else { return } analytics.contentPageShowCompletedSubsectionClicked( @@ -546,9 +546,9 @@ public final class CourseContainerViewModel: BaseCourseViewModel { func trackCourseHomeAssignmentClicked(blockId: String, blockName: String) { guard let course = courseStructure else { return } analytics.courseHomeAssignmentClicked(courseId: course.id, - courseName: course.displayName, - blockId: blockId, - blockName: blockName + courseName: course.displayName, + blockId: blockId, + blockName: blockName ) } @@ -561,7 +561,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { blockName: sequential.displayName ) } - + func trackResumeCourseClicked(blockId: String) { guard let course = courseStructure else { return } analytics.resumeCourseClicked( @@ -570,7 +570,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { blockId: blockId ) } - + func completeBlock( chapterID: String, sequentialID: String, @@ -586,14 +586,14 @@ public final class CourseContainerViewModel: BaseCourseViewModel { .childs.firstIndex(where: { $0.id == sequentialID }) else { return } - + guard let verticalIndex = courseStructure? .childs[chapterIndex] .childs[sequentialIndex] .childs.firstIndex(where: { $0.id == verticalID }) else { return } - + guard let blockIndex = courseStructure? .childs[chapterIndex] .childs[sequentialIndex] @@ -601,20 +601,20 @@ public final class CourseContainerViewModel: BaseCourseViewModel { .childs.firstIndex(where: { $0.id == blockID }) else { return } - + courseStructure? .childs[chapterIndex] .childs[sequentialIndex] .childs[verticalIndex] .childs[blockIndex].completion = 1 - + if let courseStructure { courseVideosStructure = await interactor.getCourseVideoBlocks(fullStructure: courseStructure) courseAssignmentsStructure = await interactor.getCourseAssignmentBlocks(fullStructure: courseStructure) updateAssignmentSections() } } - + func hasVideoForDowbloads() -> Bool { guard let courseVideosStructure = courseVideosStructure else { return false @@ -623,7 +623,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { .flatMap { $0.childs } .contains(where: { $0.isDownloadable }) } - + func isAllDownloading() -> Bool { let totalCount = downloadableVerticals.count let downloadingCount = downloadableVerticals.filter { $0.state == .downloading }.count @@ -631,7 +631,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { if finishedCount == totalCount { return false } return totalCount - finishedCount == downloadingCount } - + @MainActor func isAllDownloaded() -> Bool { guard let course = courseStructure else { return false } @@ -651,7 +651,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } return true } - + @MainActor func download(state: DownloadViewState, blocks: [CourseBlock], sequentials: [CourseSequential]) async { do { @@ -669,7 +669,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } } } - + private func presentNoInternetAlert(sequentials: [CourseSequential]) { router.presentView( transitionStyle: .coverVertical, @@ -684,7 +684,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { completion: {} ) } - + private func presentWifiRequiredAlert(sequentials: [CourseSequential]) { router.presentView( transitionStyle: .coverVertical, @@ -699,7 +699,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { completion: {} ) } - + @MainActor private func presentConfirmDownloadCellularAlert( blocks: [CourseBlock], @@ -733,7 +733,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { completion: {} ) } - + private func presentStorageFullAlert(sequentials: [CourseSequential]) { router.presentView( transitionStyle: .coverVertical, @@ -749,7 +749,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { completion: {} ) } - + @MainActor private func presentConfirmDownloadAlert( blocks: [CourseBlock], @@ -784,7 +784,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { completion: {} ) } - + private func presentRemoveDownloadAlert(blocks: [CourseBlock], sequentials: [CourseSequential]) async { router.presentView( transitionStyle: .coverVertical, @@ -809,7 +809,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { completion: {} ) } - + @MainActor func collectBlocks( chapter: CourseChapter, @@ -819,18 +819,18 @@ public final class CourseContainerViewModel: BaseCourseViewModel { ) async -> [CourseBlock] { let sequentials = chapter.childs.filter { $0.id == blockId } guard !sequentials.isEmpty else { return [] } - + let blocks = sequentials.flatMap { $0.childs.flatMap { $0.childs } } .filter { $0.isDownloadable && (!videoOnly || $0.type == .video) } - + if state == .available, await isShowedAllowLargeDownloadAlert(blocks: blocks) { return [] } - + guard let sequential = chapter.childs.first(where: { $0.id == blockId }) else { return [] } - + if state == .available { analytics.bulkDownloadVideosSubsection( courseID: courseStructure?.id ?? "", @@ -845,10 +845,10 @@ public final class CourseContainerViewModel: BaseCourseViewModel { videos: blocks.count ) } - + return blocks } - + @MainActor func isShowedAllowLargeDownloadAlert(blocks: [CourseBlock]) async -> Bool { waitingDownloads = nil @@ -873,13 +873,13 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } return false } - + @MainActor func downloadAll() async { guard let course = courseStructure else { return } var blocksToDownload: [CourseBlock] = [] var sequentialsToDownload: [CourseSequential] = [] - + for chapter in course.childs { for sequential in chapter.childs where sequential.isDownloadable { let blocks = downloadableBlocks(from: sequential) @@ -898,10 +898,10 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } } } - + if !blocksToDownload.isEmpty { let totalFileSize = blocksToDownload.reduce(0) { $0 + ($1.fileSize ?? 0) } - + if !connectivity.isInternetAvaliable { presentNoInternetAlert(sequentials: sequentialsToDownload) } else if connectivity.isMobileData { @@ -936,12 +936,12 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } } } - + @MainActor func isBlockDownloaded(_ block: CourseBlock) -> Bool { courseDownloadTasks.contains { $0.blockId == block.id && $0.state == .finished } } - + @MainActor func stopAllDownloads() async { do { @@ -951,7 +951,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { errorMessage = CoreLocalization.Error.unknownError } } - + @MainActor func downloadableBlocks(from sequential: CourseSequential) -> [CourseBlock] { let verticals = sequential.childs @@ -960,7 +960,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { .filter { $0.isDownloadable } return blocks } - + private func getFileSize(at url: URL) -> Int? { do { let fileAttributes = try FileManager.default.attributesOfItem(atPath: url.path) @@ -993,19 +993,19 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } return nil } - + private func isEnoughSpace(for fileSize: Int) -> Bool { if let freeSpace = manager.getFreeDiskSpace() { return freeSpace > Int(Double(fileSize) * 1.2) } return false } - + private func getUsedDiskSpace() -> Int? { do { let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory() as String) if let totalSpace = attributes[.systemSize] as? Int64, - let freeSpace = attributes[.systemFreeSize] as? Int64 { + let freeSpace = attributes[.systemFreeSize] as? Int64 { return Int(totalSpace - freeSpace) } } catch { @@ -1013,7 +1013,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } return nil } - + // MARK: Larges Downloads @MainActor func removeBlock(_ block: CourseBlock) async { @@ -1043,7 +1043,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { completion: {} ) } - + @MainActor func removeAllBlocks() async { let totalSize = courseDownloadTasks.reduce(0, { $0 + $1.actualSize }) @@ -1054,7 +1054,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } return false } - + router.presentView( transitionStyle: .coverVertical, view: DownloadActionView( @@ -1080,7 +1080,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { completion: {} ) } - + private func update(from value: CourseDownloadValue) { downloadableVerticals = value.downloadableVerticals downloadAllButtonState = value.state @@ -1095,7 +1095,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { private func initializeExpandedSections() { guard let courseStructure = courseStructure else { return } - + for chapter in courseStructure.childs { let progress = chapterProgress(for: chapter) let isNotCompleted = progress < 1.0 @@ -1129,10 +1129,10 @@ public final class CourseContainerViewModel: BaseCourseViewModel { func chapterProgress(for chapter: CourseChapter) -> Double { guard !chapter.childs.isEmpty else { return 0.0 } - + let totalProgress = chapter.childs.reduce(0.0) { $0 + $1.completion } let averageProgress = totalProgress / Double(chapter.childs.count) - + return max(0.0, min(1.0, averageProgress)) } @@ -1158,19 +1158,14 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } .store(in: &cancellables) - connectivity.internetReachableSubject - .sink { [weak self] _ in - guard let self else { return } - self.isInternetAvaliable = self.connectivity.isInternetAvaliable - } - .store(in: &cancellables) + observeConnectivity() NotificationCenter.default.addObserver( self, selector: #selector(handleShiftDueDates), name: .shiftCourseDates, object: nil ) - + completionPublisher .receive(on: DispatchQueue.main) .sink { [weak self] notification in @@ -1185,10 +1180,16 @@ public final class CourseContainerViewModel: BaseCourseViewModel { .store(in: &cancellables) } - deinit { - NotificationCenter.default.removeObserver(self) + private func observeConnectivity() { + connectivity.internetReachableSubject + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + self.isInternetAvaliable = self.connectivity.isInternetAvaliable + } + .store(in: &cancellables) } - + func handleVideoTap(video: CourseBlock, chapter: CourseChapter?) { // Find indices for navigation using full course structure guard let chapterIndex = findChapterIndexInFullStructure(video: video), @@ -1197,7 +1198,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { let courseStructure = courseStructure else { return } - + // Track video click analytics analytics.courseVideoClicked( courseId: courseStructure.id, @@ -1205,7 +1206,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { blockId: video.id, blockName: video.displayName ) - + router.showCourseUnit( courseName: courseStructure.displayName, blockId: video.id, @@ -1218,10 +1219,10 @@ public final class CourseContainerViewModel: BaseCourseViewModel { courseVideoStructure: courseStructure ) } - + private func findChapterIndexInFullStructure(video: CourseBlock) -> Int? { guard let courseStructure = courseStructure else { return nil } - + // Find the chapter that contains this video in the full structure return courseStructure.childs.firstIndex { fullChapter in fullChapter.childs.contains { sequential in @@ -1231,10 +1232,10 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } } } - + private func findSequentialIndexInFullStructure(video: CourseBlock) -> Int? { guard let courseStructure = courseStructure else { return nil } - + // Find the chapter and sequential that contains this video in the full structure for fullChapter in courseStructure.childs { if let sequentialIndex = fullChapter.childs.firstIndex(where: { sequential in @@ -1247,10 +1248,10 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } return nil } - + private func findVerticalIndexInFullStructure(video: CourseBlock) -> Int? { guard let courseStructure = courseStructure else { return nil } - + // Find the vertical that contains this video in the full structure for fullChapter in courseStructure.childs { for sequential in fullChapter.childs { @@ -1270,41 +1271,82 @@ public final class CourseContainerViewModel: BaseCourseViewModel { let updatedStructure = updateBlockProgress(in: courseStructure, blockID: blockID, progress: progress) self.courseStructure = updatedStructure } - + if let courseStructure = courseStructure { let videoStructure = await interactor.getCourseVideoBlocks(fullStructure: courseStructure) self.courseVideosStructure = videoStructure self.courseAssignmentsStructure = await interactor.getCourseAssignmentBlocks(fullStructure: courseStructure) updateAssignmentSections() } + } + + @MainActor + public func refreshLocalVideoProgress() async { + guard !isRefreshingVideoProgress else { + return + } + + guard let courseStructure = courseStructure else { + return + } + + isRefreshingVideoProgress = true + + var updatedStructure = courseStructure + var videosProcessed = 0 + var videosUpdated = 0 + + for (chapterIndex, chapter) in courseStructure.childs.enumerated() { + for (sequentialIndex, sequential) in chapter.childs.enumerated() { + for (verticalIndex, vertical) in sequential.childs.enumerated() { + for (blockIndex, block) in vertical.childs.enumerated() where block.type == .video { + videosProcessed += 1 + + if let progress = await interactor.loadLocalVideoProgress(blockID: block.id) { + videosUpdated += 1 + updatedStructure + .childs[chapterIndex] + .childs[sequentialIndex] + .childs[verticalIndex] + .childs[blockIndex].localVideoProgress = progress + } + } + } + } + } + + self.courseStructure = updatedStructure - objectWillChange.send() + if courseVideosStructure != nil { + let newVideoStructure = await interactor.getCourseVideoBlocks(fullStructure: updatedStructure) + self.courseVideosStructure = newVideoStructure + } + + isRefreshingVideoProgress = false } - + @MainActor func updateAssignmentProgress(blockID: String, progress: Double) async { if let courseStructure = courseStructure { let updatedStructure = updateBlockProgress(in: courseStructure, blockID: blockID, progress: progress) self.courseStructure = updatedStructure } - + if let courseStructure = courseStructure { let assignmentStructure = await interactor.getCourseAssignmentBlocks(fullStructure: courseStructure) self.courseAssignmentsStructure = assignmentStructure self.courseVideosStructure = await interactor.getCourseVideoBlocks(fullStructure: courseStructure) updateAssignmentSections() } - - objectWillChange.send() } - + private func updateBlockProgress( in structure: CourseStructure, blockID: String, progress: Double ) -> CourseStructure { var updatedStructure = structure - + for (chapterIndex, chapter) in structure.childs.enumerated() { for (sequentialIndex, sequential) in chapter.childs.enumerated() { for (verticalIndex, vertical) in sequential.childs.enumerated() { @@ -1321,10 +1363,10 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } } } - + return updatedStructure } - + func courseProgress() -> CourseProgress? { guard let course = courseStructure else { return nil } let total = course.childs.count @@ -1332,19 +1374,19 @@ public final class CourseContainerViewModel: BaseCourseViewModel { let completed = course.childs.filter { chapterProgress(for: $0) >= 1.0 }.count return CourseProgress(totalAssignmentsCount: total, assignmentsCompleted: completed) } - + func assignmentTypeProgress(for assignmentType: String) -> AssignmentProgressData? { guard let progressDetails = courseProgressDetails else { return nil } - + let subsectionsOfType = progressDetails.sectionScores.flatMap { $0.subsections } .filter { $0.assignmentType == assignmentType } - + guard !subsectionsOfType.isEmpty else { return nil } - + let totalPoints = subsectionsOfType.reduce(0) { $0 + $1.numPointsPossible } let earnedPoints = subsectionsOfType.reduce(0) { $0 + $1.numPointsEarned } let completed = subsectionsOfType.filter { $0.numPointsEarned >= $0.numPointsPossible }.count - + return AssignmentProgressData( completed: completed, total: subsectionsOfType.count, @@ -1352,18 +1394,18 @@ public final class CourseContainerViewModel: BaseCourseViewModel { possiblePoints: totalPoints ) } - + func assignmentTypeWeight(for assignmentType: String) -> Double? { guard let progressDetails = courseProgressDetails else { return nil } - + return progressDetails.gradingPolicy.assignmentPolicies .first { $0.type == assignmentType }? .weight } - + func assignmentTypeLabel(for assignmentType: String) -> String? { guard let progressDetails = courseProgressDetails else { return nil } - + return progressDetails.gradingPolicy.assignmentPolicies .first { $0.type == assignmentType }? .type @@ -1372,24 +1414,18 @@ public final class CourseContainerViewModel: BaseCourseViewModel { func assignmentTypeColor(for assignmentType: String) -> String? { guard let progressDetails = courseProgressDetails else { return nil } - guard let index = progressDetails.gradingPolicy.assignmentPolicies - .firstIndex(where: { $0.type == assignmentType }) else { - return nil + if let index = progressDetails.gradingPolicy.assignmentPolicies + .firstIndex(where: { $0.type == assignmentType }) { + let colors = progressDetails.gradingPolicy.assignmentColors + return index < colors.count ? colors[index] : nil } - let colors = progressDetails.gradingPolicy.assignmentColors - - guard !colors.isEmpty else { return nil } - - let colorIndex = index % colors.count - let hexColor = colors[colorIndex] - - return hexColor + return nil } func getSequentialShortLabel(for blockKey: String) -> String? { guard let courseStructure = courseAssignmentsStructure ?? courseStructure else { return nil } - + for chapter in courseStructure.childs { for sequential in chapter.childs { if sequential.blockId == blockKey || sequential.id == blockKey { @@ -1399,28 +1435,28 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } return nil } - + func getSequentialAssignmentStatus(for blockKey: String) -> AssignmentCardStatus? { guard let courseStructure = courseAssignmentsStructure ?? courseStructure else { return nil } - + for chapter in courseStructure.childs { for sequential in chapter.childs { if sequential.blockId == blockKey || sequential.id == blockKey { if sequential.completion >= 1.0 { return .completed } - + if let due = sequential.due, due < Date() { return .pastDue } - + return .incomplete } } } return nil } - + private func createUIModels(from subsections: [CourseProgressSubsection]) -> [CourseProgressSubsectionUI] { return subsections.map { subsection in let shortLabel = getSequentialShortLabel(for: subsection.blockKey) ?? "" @@ -1468,7 +1504,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { assignmentSectionsData = [] return } - + let subsectionsByType = Dictionary( grouping: progressDetails.sectionScores.flatMap { $0.subsections } ) { subsection in @@ -1490,18 +1526,18 @@ public final class CourseContainerViewModel: BaseCourseViewModel { subsections: uiSubsections ) } - + } - + func assignmentSections() -> [AssignmentSection] { return assignmentSectionsData } - + // MARK: - Assignment Deadline Methods - + func getAssignmentDeadline(for subsection: CourseProgressSubsection) -> CourseDateBlock? { guard let courseDeadlines = courseDeadlines else { return nil } - + // Trying to find deadline by Blockkey or other parameters return courseDeadlines.courseDateBlocks.first { dateBlock in // Binding by AssignmentType and name @@ -1509,55 +1545,55 @@ public final class CourseContainerViewModel: BaseCourseViewModel { dateBlock.firstComponentBlockID.contains(subsection.blockKey) } } - + func getAssignmentStatus( - for subsection: CourseProgressSubsection + for subsection: CourseProgressSubsection ) -> AssignmentCardStatus { - // 1. No access - guard subsection.learnerHasAccess else { - return .notAvailable - } + // 1. No access + guard subsection.learnerHasAccess else { + return .notAvailable + } - // 2. Completed - if subsection.numPointsEarned >= subsection.numPointsPossible { - return .completed - } + // 2. Completed + if subsection.numPointsEarned >= subsection.numPointsPossible { + return .completed + } - // 3. Past due? - if isPastDue(subsection) { - return .pastDue - } + // 3. Past due? + if isPastDue(subsection) { + return .pastDue + } - // 4. All other cases - return .incomplete + // 4. All other cases + return .incomplete } // Helper function to check if past due: private func isPastDue( - _ subsection: CourseProgressSubsection + _ subsection: CourseProgressSubsection ) -> Bool { - guard - let structure = courseAssignmentsStructure ?? courseStructure - else { - return false - } - - // Flatten all sequentials into one array and find by key - let allSequentials = structure.childs.flatMap { $0.childs } - if let seq = allSequentials.first( - where: { $0.blockId == subsection.blockKey || $0.id == subsection.blockKey } - ), - let due = seq.due, - due < Date() { - return true - } + guard + let structure = courseAssignmentsStructure ?? courseStructure + else { + return false + } - return false + // Flatten all sequentials into one array and find by key + let allSequentials = structure.childs.flatMap { $0.childs } + if let seq = allSequentials.first( + where: { $0.blockId == subsection.blockKey || $0.id == subsection.blockKey } + ), + let due = seq.due, + due < Date() { + return true + } + + return false } - + func getDaysUntilDeadline(for subsection: CourseProgressSubsection) -> Int? { guard let courseStructure = courseAssignmentsStructure ?? courseStructure else { return nil } - + for chapter in courseStructure.childs { for sequential in chapter.childs { if sequential.blockId == subsection.blockKey || sequential.id == subsection.blockKey { @@ -1573,10 +1609,10 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } return nil } - + func getAssignmentDueDate(for subsection: CourseProgressSubsection) -> Date? { guard let courseStructure = courseAssignmentsStructure ?? courseStructure else { return nil } - + for chapter in courseStructure.childs { for sequential in chapter.childs { if sequential.blockId == subsection.blockKey || sequential.id == subsection.blockKey { @@ -1586,7 +1622,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } return nil } - + func clearShortLabel(_ text: String) -> String { let words = text.split(separator: " ") @@ -1602,14 +1638,14 @@ public final class CourseContainerViewModel: BaseCourseViewModel { return leftShort + rightClean } - + private func computeStatusText( for subsection: CourseProgressSubsection, status: AssignmentCardStatus, shortLabel: String? ) -> String { let cleanShortLabel = clearShortLabel(shortLabel ?? "") - + switch status { case .completed: return CourseLocalization.AssignmentStatus @@ -1654,9 +1690,9 @@ public final class CourseContainerViewModel: BaseCourseViewModel { func getAssignmentStatusText(for subsection: CourseProgressSubsection) -> String { let status = getAssignmentStatus(for: subsection) - + let shortLabel = clearShortLabel(subsection.shortLabel ?? "") - + switch status { case .completed: return CourseLocalization.AssignmentStatus @@ -1675,32 +1711,32 @@ public final class CourseContainerViewModel: BaseCourseViewModel { } } } - + func getAssignmentSequenceName(for subsection: CourseProgressSubsection) -> String { // Trying to find Sequence Name from Course Structure guard let courseStructure = courseStructure else { return CourseLocalization.Assignment.unknownSequence } - + // Looking for a block in the structure of the course for chapter in courseStructure.childs { for sequential in chapter.childs { for vertical in sequential.childs where vertical.childs .contains(where: { $0.id == subsection.blockKey }) { - return sequential.displayName - } + return sequential.displayName + } } } - + return subsection.displayName } - + func navigateToAssignment(for subsection: CourseProgressSubsection) { guard let courseStructure = courseStructure else { return } for (chapterIndex, chapter) in courseStructure.childs.enumerated() { for (sequentialIndex, sequential) in chapter.childs.enumerated() - where sequential.id == subsection.blockKey { + where sequential.id == subsection.blockKey { guard let courseVertical = sequential.childs.first else { return } guard let firstBlock = courseVertical.childs.first else { router.showGatedContentError(url: courseVertical.webUrl) @@ -1755,7 +1791,7 @@ extension CourseContainerViewModel { } } } - + func resetDueDatesShiftedFlag() { dueDatesShifted = false } @@ -1764,7 +1800,7 @@ extension CourseContainerViewModel { public struct VerticalsDownloadState: Hashable, Sendable { public let vertical: CourseVertical public let state: DownloadViewState - + public var downloadableBlocks: [CourseBlock] { vertical.childs.filter { $0.isDownloadable && $0.type == .video } } diff --git a/Course/Course/Presentation/Container/CourseDownloadHelper.swift b/Course/Course/Presentation/Container/CourseDownloadHelper.swift index f844ca262..065bf7856 100644 --- a/Course/Course/Presentation/Container/CourseDownloadHelper.swift +++ b/Course/Course/Presentation/Container/CourseDownloadHelper.swift @@ -82,18 +82,21 @@ public final class CourseDownloadHelper: CourseDownloadHelperProtocol, @unchecke public init (courseStructure: CourseStructure?, manager: DownloadManagerProtocol) { self.manager = manager self.courseStructure = courseStructure + manager.eventPublisher() .sink { [weak self] state in guard let self else { return } self.queue.async {[weak self] in if case let .progress(currentTask) = state { - if let value = self?.value { - var newValue = value - newValue.setCurrentDownloadTask(task: currentTask) - self?.value = newValue + Task { @MainActor [weak self] in + if let value = self?.value { + var newValue = value + newValue.setCurrentDownloadTask(task: currentTask) + self?.value = newValue + } + self?.sourceProgressPublisher.send(currentTask) } - self?.sourceProgressPublisher.send(currentTask) return } @@ -114,8 +117,12 @@ public final class CourseDownloadHelper: CourseDownloadHelperProtocol, @unchecke } public func refreshValue() async { - guard let courseStructure else { return } + guard let courseStructure else { + return + } + let downloadTasks = await manager.getDownloadTasks() + await enumerate( tasks: downloadTasks, courseStructure: courseStructure, @@ -154,7 +161,10 @@ public final class CourseDownloadHelper: CourseDownloadHelperProtocol, @unchecke ) async { await withCheckedContinuation { continuation in queue.async {[weak self] in - guard let self else { return } + guard let self else { + continuation.resume() + return + } let notFinishedTasks: [DownloadDataTask] = tasks.filter { $0.state != .finished } .sorted(by: { $0.state.order < $1.state.order }) let courseDownloadTasks = tasks.filter { $0.courseId == courseStructure.id } @@ -253,11 +263,11 @@ public final class CourseDownloadHelper: CourseDownloadHelperProtocol, @unchecke state: downloadState ) - self.value = value - DispatchQueue.main.async { - self.sourcePublisher.send(value) + Task { @MainActor [weak self] in + self?.value = value + self?.sourcePublisher.send(value) } - + continuation.resume() } } diff --git a/Course/Course/Presentation/Content/CourseContentView.swift b/Course/Course/Presentation/Content/CourseContentView.swift index 45f6fa9ff..758debe60 100644 --- a/Course/Course/Presentation/Content/CourseContentView.swift +++ b/Course/Course/Presentation/Content/CourseContentView.swift @@ -14,7 +14,7 @@ import SwiftUIIntrospect public struct CourseContentView: View { - @StateObject private var viewModel: CourseContainerViewModel + @Bindable private var viewModel: CourseContainerViewModel private let title: String private let courseID: String @State private var openCertificateView: Bool = false @@ -68,7 +68,7 @@ public struct CourseContentView: View { viewHeight: Binding ) { self.title = title - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel self.courseID = courseID self._selection = selection self._coordinate = coordinate @@ -254,6 +254,11 @@ public struct CourseContentView: View { await viewModel.updateVideoProgress(blockID: blockID, progress: progress) } } + .onAppear { + Task { + await viewModel.refreshLocalVideoProgress() + } + } case .assignments: AssignmentsContentView( assignmentContentData: assignmentContentData, diff --git a/Course/Course/Presentation/Content/Subviews/AllContentView.swift b/Course/Course/Presentation/Content/Subviews/AllContentView.swift index 04eda63fd..0ae8cb890 100644 --- a/Course/Course/Presentation/Content/Subviews/AllContentView.swift +++ b/Course/Course/Presentation/Content/Subviews/AllContentView.swift @@ -13,7 +13,7 @@ import Theme struct AllContentView: View { - @StateObject private var viewModel: CourseContainerViewModel + @Bindable private var viewModel: CourseContainerViewModel private let title: String private let courseID: String private let dateTabIndex: Int @@ -32,7 +32,7 @@ struct AllContentView: View { dateTabIndex: Int ) { self.title = title - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel self.proxy = proxy self.courseID = courseID self.dateTabIndex = dateTabIndex diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index 5de224b44..d42a939e5 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -15,8 +15,7 @@ import SwiftUIIntrospect public struct CourseDatesView: View { private let courseID: String - - @StateObject + private var viewModel: CourseDatesViewModel @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool @@ -33,7 +32,7 @@ public struct CourseDatesView: View { self._coordinate = coordinate self._collapsed = collapsed self._viewHeight = viewHeight - self._viewModel = StateObject(wrappedValue: viewModel) + self.viewModel = viewModel } public var body: some View { diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index 8ab0d022e..c2473700c 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -11,7 +11,8 @@ import SwiftUI import OEXFoundation @MainActor -public class CourseDatesViewModel: ObservableObject { +@Observable +public class CourseDatesViewModel { enum EventState: Sendable { case addedCalendar @@ -21,18 +22,15 @@ public class CourseDatesViewModel: ObservableObject { case none } - @Published var isShowProgress = true - @Published var showError: Bool = false - @Published var courseDates: CourseDates? - @Published var isOn: Bool = false - @Published var eventState: EventState? + var isShowProgress = true + var courseDates: CourseDates? + var isOn: Bool = false + var eventState: EventState? - var errorMessage: String? { - didSet { - withAnimation { - showError = errorMessage != nil - } - } + var errorMessage: String? + + var showError: Bool { + errorMessage != nil } private let interactor: CourseInteractorProtocol diff --git a/Course/Course/Presentation/Dates/Elements/CourseDateListView.swift b/Course/Course/Presentation/Dates/Elements/CourseDateListView.swift index d31c9a832..bb374f73d 100644 --- a/Course/Course/Presentation/Dates/Elements/CourseDateListView.swift +++ b/Course/Course/Presentation/Dates/Elements/CourseDateListView.swift @@ -10,7 +10,9 @@ import Core import Theme struct CourseDateListView: View { - @ObservedObject var viewModel: CourseDatesViewModel + + var viewModel: CourseDatesViewModel + @State private var isExpanded = false @Binding var coordinate: CGFloat @Binding var collapsed: Bool diff --git a/Course/Course/Presentation/Downloads/DownloadsView.swift b/Course/Course/Presentation/Downloads/DownloadsView.swift index 61ae87026..c9e0ab507 100644 --- a/Course/Course/Presentation/Downloads/DownloadsView.swift +++ b/Course/Course/Presentation/Downloads/DownloadsView.swift @@ -16,7 +16,7 @@ public struct DownloadsView: View { @Environment(\.dismiss) private var dismiss @Environment(\.isHorizontal) private var isHorizontal - @StateObject private var viewModel: DownloadsViewModel + private var viewModel: DownloadsViewModel var isSheet: Bool = true @@ -26,12 +26,11 @@ public struct DownloadsView: View { courseHelper: CourseDownloadHelperProtocol ) { self.isSheet = isSheet - self._viewModel = .init( - wrappedValue: .init( - router: router, - helper: courseHelper - ) + self.viewModel = DownloadsViewModel( + router: router, + helper: courseHelper ) + } // MARK: - Body diff --git a/Course/Course/Presentation/Downloads/DownloadsViewModel.swift b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift index 3b902d644..8410fcfba 100644 --- a/Course/Course/Presentation/Downloads/DownloadsViewModel.swift +++ b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift @@ -11,11 +11,12 @@ import OEXFoundation @preconcurrency import Combine @MainActor -final class DownloadsViewModel: ObservableObject { +@Observable +final class DownloadsViewModel { // MARK: - Properties - @Published private(set) var downloads: [DownloadDataTask] = [] + private(set) var downloads: [DownloadDataTask] = [] let router: CourseRouter @@ -66,8 +67,11 @@ final class DownloadsViewModel: ObservableObject { .store(in: &cancellables) helper.progressPublisher() .sink {[weak self] task in - if let firstIndex = self?.downloads.firstIndex(where: { $0.id == task.id }) { - self?.downloads[firstIndex].progress = task.progress + guard let self = self else { return } + if let firstIndex = self.downloads.firstIndex(where: { $0.id == task.id }) { + var updatedDownloads = self.downloads + updatedDownloads[firstIndex].progress = task.progress + self.downloads = updatedDownloads } } .store(in: &cancellables) diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index 82d74a408..e69d57ef6 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -15,8 +15,7 @@ struct HandoutsView: View { @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool @Binding private var viewHeight: CGFloat - - @StateObject + private var viewModel: HandoutsViewModel public init( @@ -30,7 +29,7 @@ struct HandoutsView: View { self._coordinate = coordinate self._collapsed = collapsed self._viewHeight = viewHeight - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel } public var body: some View { diff --git a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift index c1756d9ed..140867b50 100644 --- a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift +++ b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift @@ -10,19 +10,17 @@ import Core import SwiftUI @MainActor -public final class HandoutsViewModel: ObservableObject { +@Observable +public final class HandoutsViewModel { - @Published private(set) var isShowProgress = false - @Published var showError: Bool = false - @Published var handouts: String? - @Published var updates: [CourseUpdate] = [] - - var errorMessage: String? { - didSet { - withAnimation { - showError = errorMessage != nil - } - } + private(set) var isShowProgress = false + var handouts: String? + var updates: [CourseUpdate] = [] + + var errorMessage: String? + + var showError: Bool { + errorMessage != nil } private let interactor: CourseInteractorProtocol diff --git a/Course/Course/Presentation/NewOutlIineAndProgress/ CourseOutlineAndProgressViewModel.swift b/Course/Course/Presentation/NewOutlIineAndProgress/ CourseOutlineAndProgressViewModel.swift index e2da524af..6c6c3dc9b 100644 --- a/Course/Course/Presentation/NewOutlIineAndProgress/ CourseOutlineAndProgressViewModel.swift +++ b/Course/Course/Presentation/NewOutlIineAndProgress/ CourseOutlineAndProgressViewModel.swift @@ -6,43 +6,41 @@ import OEXFoundation import Combine @MainActor -public class CourseOutlineAndProgressViewModel: ObservableObject { +@Observable +public class CourseOutlineAndProgressViewModel { // MARK: - Variables - @Published public var courseProgress: CourseProgressDetails? - @Published public var showError: Bool = false - @Published public var selection: Int - @Published var userSettings: UserSettings? - @Published var isInternetAvaliable: Bool = true + public var courseProgress: CourseProgressDetails? + public var selection: Int + var userSettings: UserSettings? + var isInternetAvaliable: Bool = true let router: CourseRouter let analytics: CourseAnalytics let connectivity: ConnectivityProtocol let interactor: CourseInteractorProtocol let config: ConfigProtocol - + let isActive: Bool? let courseStart: Date? let courseEnd: Date? let enrollmentStart: Date? let enrollmentEnd: Date? let lastVisitedBlockID: String? - + var courseDownloadTasks: [DownloadDataTask] = [] private(set) var waitingDownloads: [CourseBlock]? - + private let authInteractor: AuthInteractorProtocol private(set) var storage: CourseStorage - + private let cellularFileSizeLimit: Int = 100 * 1024 * 1024 var courseHelper: CourseDownloadHelperProtocol - - public var errorMessage: String? { - didSet { - withAnimation { - showError = errorMessage != nil - } - } + + public var errorMessage: String? + + public var showError: Bool { + errorMessage != nil } // MARK: - Init diff --git a/Course/Course/Presentation/NewOutlIineAndProgress/CourseOutlineAndProgressView.swift b/Course/Course/Presentation/NewOutlIineAndProgress/CourseOutlineAndProgressView.swift index 7c96a93c4..6de615587 100644 --- a/Course/Course/Presentation/NewOutlIineAndProgress/CourseOutlineAndProgressView.swift +++ b/Course/Course/Presentation/NewOutlIineAndProgress/CourseOutlineAndProgressView.swift @@ -9,8 +9,8 @@ import WhatsNew public struct CourseOutlineAndProgressView: View { // MARK: - Variables - @StateObject private var viewModelContainer: CourseContainerViewModel - @StateObject private var viewModelProgress: CourseProgressViewModel + @Bindable private var viewModelContainer: CourseContainerViewModel + private var viewModelProgress: CourseProgressViewModel private let title: String private let courseID: String private let isVideo: Bool @@ -94,8 +94,8 @@ public struct CourseOutlineAndProgressView: View { connectivity: ConnectivityProtocol ) { self.title = title - self._viewModelContainer = StateObject(wrappedValue: { viewModelContainer }()) - self._viewModelProgress = StateObject(wrappedValue: { viewModelProgress}()) + self.viewModelContainer = viewModelContainer + self.viewModelProgress = viewModelProgress self.courseID = courseID self.isVideo = isVideo self._selection = selection @@ -109,6 +109,7 @@ public struct CourseOutlineAndProgressView: View { // MARK: - Body public var body: some View { ZStack(alignment: .top) { + // MARK: - RETURN THIS! if viewModelProgress.isLoading || viewModelContainer.isShowRefresh { HStack(alignment: .center) { ProgressBar(size: 40, lineWidth: 8) @@ -116,6 +117,7 @@ public struct CourseOutlineAndProgressView: View { .padding(.horizontal) } } else { + // MARK: - RETURN THIS! GeometryReader { _ in VStack(alignment: .center) { // MARK: - Page Body @@ -175,11 +177,13 @@ public struct CourseOutlineAndProgressView: View { .opacity(viewModelProgress.isLoading || viewModelContainer.isShowProgress ? 0 : 1) } .onAppear { - if viewModelProgress.courseProgress == nil { - Task { + Task { + if viewModelProgress.courseProgress == nil { await viewModelProgress.getCourseProgress(courseID: courseID) await viewModelContainer .getCourseBlocks(courseID: courseID, withProgress: false) + } else { + await viewModelContainer.refreshLocalVideoProgress() } } } diff --git a/Course/Course/Presentation/Offline/OfflineView.swift b/Course/Course/Presentation/Offline/OfflineView.swift index 168e9e574..e795d858a 100644 --- a/Course/Course/Presentation/Offline/OfflineView.swift +++ b/Course/Course/Presentation/Offline/OfflineView.swift @@ -58,8 +58,7 @@ struct OfflineView: View { @Binding private var collapsed: Bool @Binding private var viewHeight: CGFloat - @StateObject - private var viewModel: CourseContainerViewModel + @Bindable private var viewModel: CourseContainerViewModel public init( courseID: String, @@ -72,7 +71,7 @@ struct OfflineView: View { self._coordinate = coordinate self._collapsed = collapsed self._viewHeight = viewHeight - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel } public var body: some View { diff --git a/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift b/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift index 3c13cd278..579a62ea3 100644 --- a/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift +++ b/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift @@ -12,8 +12,8 @@ import Theme public struct LargestDownloadsView: View { @State private var isEditing = false - @ObservedObject - private var viewModel: CourseContainerViewModel + + @Bindable private var viewModel: CourseContainerViewModel init(viewModel: CourseContainerViewModel) { self.viewModel = viewModel diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 4049276ff..745d26c54 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -14,7 +14,7 @@ import SwiftUIIntrospect public struct CourseOutlineView: View { - @StateObject private var viewModel: CourseContainerViewModel + @Bindable private var viewModel: CourseContainerViewModel private let title: String private let courseID: String private let isVideo: Bool @@ -46,7 +46,7 @@ public struct CourseOutlineView: View { dateTabIndex: Int ) { self.title = title - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel self.courseID = courseID self.isVideo = isVideo self._selection = selection diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index 88a0efc0b..18db60009 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -16,7 +16,7 @@ public struct CourseVerticalView: View { private var title: String private var courseName: String private var courseID: String - @ObservedObject + private var viewModel: CourseVerticalViewModel private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift index 98df0cc27..4c193a769 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift @@ -9,22 +9,20 @@ import SwiftUI import Core import OEXFoundation -public final class CourseVerticalViewModel: ObservableObject, @unchecked Sendable { +@Observable +public final class CourseVerticalViewModel: @unchecked Sendable { let router: CourseRouter let analytics: CourseAnalytics let connectivity: ConnectivityProtocol - @Published var verticals: [CourseVertical] - @Published var showError: Bool = false + var verticals: [CourseVertical] let chapters: [CourseChapter] let chapterIndex: Int let sequentialIndex: Int - - var errorMessage: String? { - didSet { - withAnimation { - showError = errorMessage != nil - } - } + + var errorMessage: String? + + var showError: Bool { + errorMessage != nil } public init( diff --git a/Course/Course/Presentation/Progress/CourseProgressScreenView.swift b/Course/Course/Presentation/Progress/CourseProgressScreenView.swift index 3ee35119e..d5ab79476 100644 --- a/Course/Course/Presentation/Progress/CourseProgressScreenView.swift +++ b/Course/Course/Presentation/Progress/CourseProgressScreenView.swift @@ -17,8 +17,8 @@ struct CourseProgressScreenView: View { @Binding private var collapsed: Bool @Binding private var viewHeight: CGFloat - @StateObject - private var viewModel: CourseProgressViewModel + @Bindable private var viewModel: CourseProgressViewModel + private let initialCourseStructure: CourseStructure? private let connectivity: ConnectivityProtocol @@ -36,7 +36,7 @@ struct CourseProgressScreenView: View { self._coordinate = coordinate self._collapsed = collapsed self._viewHeight = viewHeight - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel self.connectivity = connectivity self.initialCourseStructure = courseStructure } diff --git a/Course/Course/Presentation/Progress/CourseProgressViewModel.swift b/Course/Course/Presentation/Progress/CourseProgressViewModel.swift index 5f0068c52..f7ace021e 100644 --- a/Course/Course/Presentation/Progress/CourseProgressViewModel.swift +++ b/Course/Course/Presentation/Progress/CourseProgressViewModel.swift @@ -11,26 +11,24 @@ import Foundation import Theme @MainActor -public class CourseProgressViewModel: ObservableObject { - - @Published var courseProgress: CourseProgressDetails? - @Published var assignmentProgressData: [String: AssignmentProgressData] = [:] - @Published var isLoading: Bool = false - @Published var isShowRefresh = false - @Published var showError: Bool = false +@Observable +public class CourseProgressViewModel { + var courseProgress: CourseProgressDetails? + var assignmentProgressData: [String: AssignmentProgressData] = [:] + var isLoading: Bool = false + var isShowRefresh = false + let router: CourseRouter let analytics: CourseAnalytics let connectivity: ConnectivityProtocol let interactor: CourseInteractorProtocol var courseStructure: CourseStructure? - public var errorMessage: String? { - didSet { - withAnimation { - showError = errorMessage != nil - } - } + public var errorMessage: String? + + public var showError: Bool { + errorMessage != nil } public init( diff --git a/Course/Course/Presentation/Subviews/CourseCarouselView/CourseAssignmentsCarouselSlideView.swift b/Course/Course/Presentation/Subviews/CourseCarouselView/CourseAssignmentsCarouselSlideView.swift index 1d83d6910..d7c793909 100644 --- a/Course/Course/Presentation/Subviews/CourseCarouselView/CourseAssignmentsCarouselSlideView.swift +++ b/Course/Course/Presentation/Subviews/CourseCarouselView/CourseAssignmentsCarouselSlideView.swift @@ -5,8 +5,8 @@ import Core struct CourseAssignmentsCarouselSlideView: View { // MARK: - Variables - @ObservedObject var viewModelProgress: CourseProgressViewModel - @ObservedObject var viewModelContainer: CourseContainerViewModel + var viewModelProgress: CourseProgressViewModel + var viewModelContainer: CourseContainerViewModel @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.verticalSizeClass) private var verticalSizeClass diff --git a/Course/Course/Presentation/Subviews/CourseCarouselView/CourseGradeCarouselSlideView.swift b/Course/Course/Presentation/Subviews/CourseCarouselView/CourseGradeCarouselSlideView.swift index 4ba0237c0..41c724452 100644 --- a/Course/Course/Presentation/Subviews/CourseCarouselView/CourseGradeCarouselSlideView.swift +++ b/Course/Course/Presentation/Subviews/CourseCarouselView/CourseGradeCarouselSlideView.swift @@ -5,8 +5,8 @@ import Core struct CourseGradeCarouselSlideView: View { // MARK: - Variables - @ObservedObject var viewModelProgress: CourseProgressViewModel - @ObservedObject var viewModelContainer: CourseContainerViewModel + @Bindable var viewModelProgress: CourseProgressViewModel + var viewModelContainer: CourseContainerViewModel // MARK: - Body var body: some View { diff --git a/Course/Course/Presentation/Subviews/CourseCarouselView/CourseVideoCarouselSlideView.swift b/Course/Course/Presentation/Subviews/CourseCarouselView/CourseVideoCarouselSlideView.swift index b60b2262c..1d547c883 100644 --- a/Course/Course/Presentation/Subviews/CourseCarouselView/CourseVideoCarouselSlideView.swift +++ b/Course/Course/Presentation/Subviews/CourseCarouselView/CourseVideoCarouselSlideView.swift @@ -5,8 +5,8 @@ import Core struct CourseVideoCarouselSlideView: View { // MARK: - Variables - @ObservedObject var viewModelProgress: CourseProgressViewModel - @ObservedObject var viewModelContainer: CourseContainerViewModel + var viewModelProgress: CourseProgressViewModel + var viewModelContainer: CourseContainerViewModel @State private var isHidingCompletedSections = true private var videoContentData: VideoContentData { @@ -84,7 +84,7 @@ struct CourseVideoCarouselSlideView: View { guard let chapter = courseChapter else { return nil } let videos = getAllVideos(from: chapter) if let partial = videos.first(where: { - $0.localVideoProgress > 0 && $0.localVideoProgress < 1 + $0.localVideoProgress > 0 && $0.localVideoProgress < 1 && $0.completion < 1 }) { return partial } else { diff --git a/Course/Course/Presentation/Subviews/CourseHeaderView.swift b/Course/Course/Presentation/Subviews/CourseHeaderView.swift index 3f604e7df..9cbfc2385 100644 --- a/Course/Course/Presentation/Subviews/CourseHeaderView.swift +++ b/Course/Course/Presentation/Subviews/CourseHeaderView.swift @@ -12,7 +12,7 @@ import Theme struct CourseHeaderView: View { - @ObservedObject var viewModel: CourseContainerViewModel + @Bindable var viewModel: CourseContainerViewModel private var title: String private var containerWidth: CGFloat private var animationNamespace: Namespace.ID diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift index ddc9fc94e..cd816465c 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift @@ -14,7 +14,7 @@ struct CourseVideoDownloadBarView: View { // MARK: - Properties - @StateObject var viewModel: CourseVideoDownloadBarViewModel + var viewModel: CourseVideoDownloadBarViewModel private var onTap: (() -> Void)? private var onNotInternetAvaliable: (() -> Void)? @@ -25,13 +25,12 @@ struct CourseVideoDownloadBarView: View { onTap: (() -> Void)? = nil, analytics: CourseAnalytics ) { - self._viewModel = .init( - wrappedValue: .init( - courseStructure: courseStructure, - courseViewModel: courseViewModel, - analytics: analytics - ) + self.viewModel = CourseVideoDownloadBarViewModel( + courseStructure: courseStructure, + courseViewModel: courseViewModel, + analytics: analytics ) + self.onNotInternetAvaliable = onNotInternetAvaliable self.onTap = onTap } diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift index ac8aa78bd..7d105dd9b 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift @@ -11,7 +11,8 @@ import OEXFoundation import Combine @MainActor -final class CourseVideoDownloadBarViewModel: ObservableObject { +@Observable +final class CourseVideoDownloadBarViewModel { // MARK: - Properties @@ -19,8 +20,8 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { private let courseViewModel: CourseContainerViewModel private let analytics: CourseAnalytics - @Published private(set) var currentDownloadTask: DownloadDataTask? - @Published private(set) var isOn: Bool = false + private(set) var currentDownloadTask: DownloadDataTask? + private(set) var isOn: Bool = false private var cancellables = Set() @@ -43,29 +44,27 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { /// total progress of downloading video files var progress: Double = 0 - var downloadableVerticals: Set = [] { - didSet { - let downloading = downloadableVerticals.filter { $0.state == .downloading } - downloadingVideos = downloading.flatMap { $0.downloadableBlocks }.count - - let finished = downloadableVerticals.filter { $0.state == .finished } - totalFinishedVideos = finished.flatMap { $0.downloadableBlocks }.count - - let inProgress = downloadableVerticals.filter { $0.state != .finished } - remainingVideos = inProgress.flatMap { $0.downloadableBlocks }.count - - let totalFinishedCount = finished.count - isAllVideosDownloaded = totalFinishedCount == downloadableVerticals.count - } - } + var downloadableVerticals: Set = [] - var isAllVideosDownloaded: Bool = false + var downloadingVideos: Int { + let downloading = downloadableVerticals.filter { $0.state == .downloading } + return downloading.flatMap { $0.downloadableBlocks }.count + } - var remainingVideos: Int = 0 + var totalFinishedVideos: Int { + let finished = downloadableVerticals.filter { $0.state == .finished } + return finished.flatMap { $0.downloadableBlocks }.count + } - var downloadingVideos: Int = 0 + var remainingVideos: Int { + let inProgress = downloadableVerticals.filter { $0.state != .finished } + return inProgress.flatMap { $0.downloadableBlocks }.count + } - var totalFinishedVideos: Int = 0 + var isAllVideosDownloaded: Bool { + let finished = downloadableVerticals.filter { $0.state == .finished } + return finished.count == downloadableVerticals.count && !downloadableVerticals.isEmpty + } var totalSize: String? diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift index 40c835205..13ab0ecc2 100644 --- a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -10,223 +10,206 @@ import Core import Theme struct CustomDisclosureGroup: View { - private let proxy: GeometryProxy + private let proxyWidth: CGFloat private let course: CourseStructure private let viewModel: CourseContainerViewModel - private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + private let idiom: UIUserInterfaceIdiom init(course: CourseStructure, proxy: GeometryProxy, viewModel: CourseContainerViewModel) { self.course = course - self.proxy = proxy + self.proxyWidth = proxy.size.width self.viewModel = viewModel + self.idiom = UIDevice.current.userInterfaceIdiom } var body: some View { VStack(alignment: .leading, spacing: 8) { - ForEach(course.childs) { chapter in - let chapterIndex = course.childs.firstIndex(where: { $0.id == chapter.id }) - VStack(alignment: .leading, spacing: 0) { - // MARK: - Progress Bar - SectionProgressView(progress: chapterProgress(for: chapter)) - .padding(.horizontal, -16) - .padding(.top, -12) - - Button( - action: { - withAnimation(.linear(duration: course.childs.count > 1 ? 0.2 : 0.05)) { - viewModel.expandedSections[chapter.id, default: false].toggle() - } - viewModel.trackSectionClicked(chapter) - }, label: { - HStack { - CoreAssets.chevronRight.swiftUIImage - .rotationEffect( - .degrees(viewModel.expandedSections[chapter.id] ?? false ? -90 : 90) - ) - .foregroundColor(Theme.Colors.textPrimary) - if chapter.childs.allSatisfy({ $0.completion == 1 }) { - CoreAssets.finishedSequence.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.success) - } - Text(chapter.displayName) - .font(Theme.Fonts.titleMedium) - .foregroundColor(Theme.Colors.textPrimary) - .lineLimit(1) - Spacer() - if canDownloadAllSections(in: chapter), - let state = downloadAllButtonState(for: chapter) { - Button( - action: { - downloadAllSubsections(in: chapter, state: state) - }, label: { - switch state { - case .available: - DownloadAvailableView() - case .downloading: - DownloadProgressView() - case .finished: - DownloadFinishedView() - } - - } - ) - } - } - } - ) - .padding(.top, 8) - if viewModel.expandedSections[chapter.id] ?? false { - VStack(alignment: .leading) { - ForEach(chapter.childs) { sequential in - let sequentialIndex = chapter.childs.firstIndex(where: { $0.id == sequential.id }) - VStack(alignment: .leading) { - HStack { - Button( - action: { - guard let chapterIndex = chapterIndex else { return } - guard let sequentialIndex else { return } - guard let courseVertical = sequential.childs.first else { return } - guard let block = courseVertical.childs.first else { - viewModel.router.showGatedContentError(url: courseVertical.webUrl) - return - } - - viewModel.trackSequentialClicked(sequential) - if viewModel.config.uiComponents.courseDropDownNavigationEnabled { - viewModel.router.showCourseUnit( - courseName: viewModel.courseStructure?.displayName ?? "", - blockId: block.id, - courseID: viewModel.courseStructure?.id ?? "", - verticalIndex: 0, - chapters: course.childs, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex, - showVideoNavigation: false, - courseVideoStructure: nil - ) - } else { - viewModel.router.showCourseVerticalView( - courseID: viewModel.courseStructure?.id ?? "", - courseName: viewModel.courseStructure?.displayName ?? "", - title: sequential.displayName, - chapters: course.childs, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex - ) - } - }, - label: { - VStack(alignment: .leading) { - HStack { - if sequential.completion == 1 { - CoreAssets.finishedSequence.swiftUIImage - .renderingMode(.template) - .resizable() - .foregroundColor(Theme.Colors.success) - .frame(width: 20, height: 20) - } else { - sequential.type.image - } - Text(sequential.displayName) - .font(Theme.Fonts.titleSmall) - .multilineTextAlignment(.leading) - .lineLimit(1) - .frame( - maxWidth: idiom == .pad - ? proxy.size.width * 0.5 - : proxy.size.width * 0.6, - alignment: .leading - ) - } - if let assignmentStatusText = assignmentStatusText( - sequential: sequential - ) { - Text(assignmentStatusText) - .font(Theme.Fonts.bodySmall) - .multilineTextAlignment(.leading) - .lineLimit(2) - } - } - .foregroundColor(Theme.Colors.textPrimary) - .accessibilityElement(children: .ignore) - .accessibilityLabel(sequential.displayName) - } - ) - Spacer() - if sequential.due != nil { - CoreAssets.chevronRight.swiftUIImage - .foregroundColor(Theme.Colors.textPrimary) - } - } - .padding(.vertical, 4) - } - } - } - .padding(.top, 8) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Theme.Colors.datesSectionBackground) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) - .foregroundColor(Theme.Colors.cardViewStroke) + ForEach(Array(course.childs.enumerated()), id: \.element.id) { chapterIndex, chapter in + ChapterRowView( + chapter: chapter, + chapterIndex: chapterIndex, + course: course, + proxyWidth: proxyWidth, + idiom: idiom, + viewModel: viewModel ) } } .padding(.horizontal, 24) .padding(.vertical, 8) + } +} +// MARK: - Chapter Row +private struct ChapterRowView: View { + let chapter: CourseChapter + let chapterIndex: Int + let course: CourseStructure + let proxyWidth: CGFloat + let idiom: UIUserInterfaceIdiom + let viewModel: CourseContainerViewModel + + private var isExpanded: Bool { + viewModel.expandedSections[chapter.id] ?? false } - private func deleteMessage(for chapter: CourseChapter) -> String { - "\(CourseLocalization.Alert.deleteVideos) \"\(chapter.displayName)\"?" + private var isChapterCompleted: Bool { + chapter.childs.allSatisfy { $0.completion == 1 } } - func getAssignmentStatus(for date: Date) -> String { - let calendar = Calendar.current - let today = Date() + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SectionProgressView(progress: viewModel.chapterProgress(for: chapter)) + .padding(.horizontal, -16) + .padding(.top, -12) + + Button( + action: { + withAnimation(.linear(duration: course.childs.count > 1 ? 0.2 : 0.05)) { + viewModel.expandedSections[chapter.id, default: false].toggle() + } + viewModel.trackSectionClicked(chapter) + }, label: { + HStack { + CoreAssets.chevronRight.swiftUIImage + .rotationEffect( + .degrees(isExpanded ? -90 : 90) + ) + .foregroundColor(Theme.Colors.textPrimary) + if isChapterCompleted { + CoreAssets.finishedSequence.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.success) + } + Text(chapter.displayName) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + .lineLimit(1) + Spacer() + if let state = downloadAllButtonState { + Button( + action: { + downloadAllSubsections(state: state) + }, label: { + switch state { + case .available: + DownloadAvailableView() + case .downloading: + DownloadProgressView() + case .finished: + DownloadFinishedView() + } + + } + ) + } + } + } + ) + .padding(.top, 8) + if isExpanded { + VStack(alignment: .leading) { + ForEach(Array(chapter.childs.enumerated()), id: \.element.id) { sequentialIndex, sequential in + SequentialRowView( + sequential: sequential, + sequentialIndex: sequentialIndex, + chapterIndex: chapterIndex, + course: course, + proxyWidth: proxyWidth, + idiom: idiom, + viewModel: viewModel + ) + } + } + .padding(.top, 8) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Theme.Colors.datesSectionBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) + .foregroundColor(Theme.Colors.cardViewStroke) + ) + } + + private var downloadAllButtonState: DownloadViewState? { + guard canDownloadAllSections else { return nil } - if calendar.isDateInToday(date) { - return CourseLocalization.Course.dueToday - } else if calendar.isDateInTomorrow(date) { - return CourseLocalization.Course.dueTomorrow - } else if let daysUntil = calendar.dateComponents([.day], from: today, to: date).day, daysUntil > 0 { - return CourseLocalization.dueIn(daysUntil) - } else if let daysAgo = calendar.dateComponents([.day], from: date, to: today).day, daysAgo > 0 { - return CourseLocalization.pastDue(daysAgo) + var downloads: [DownloadViewState] = [] + for sequential in chapter.childs { + if let state = viewModel.sequentialsDownloadState[sequential.id] { + downloads.append(state) + } + } + if downloads.contains(.downloading) { + return .downloading + } else if downloads.allSatisfy({ $0 == .finished }) { + return .finished } else { - return "" + return .available } } - private func canDownloadAllSections(in chapter: CourseChapter) -> Bool { + private var canDownloadAllSections: Bool { chapter.childs.contains { sequential in - sequentialDownloadState(sequential) != nil + viewModel.sequentialsDownloadState[sequential.id] != nil + } + } + + private func downloadAllSubsections(state: DownloadViewState) { + Task { + var allBlocks: [CourseBlock] = [] + var sequentialsToDownload: [CourseSequential] = [] + for sequential in chapter.childs { + let blocks = await viewModel.collectBlocks( + chapter: chapter, + blockId: sequential.id, + state: state + ) + if !blocks.isEmpty { + allBlocks.append(contentsOf: blocks) + sequentialsToDownload.append(sequential) + } + } + await viewModel.download( + state: state, + blocks: allBlocks, + sequentials: sequentialsToDownload + ) } } +} - private func assignmentStatusText( - sequential: CourseSequential - ) -> String? { +// MARK: - Sequential Row +private struct SequentialRowView: View { + let sequential: CourseSequential + let sequentialIndex: Int + let chapterIndex: Int + let course: CourseStructure + let proxyWidth: CGFloat + let idiom: UIUserInterfaceIdiom + let viewModel: CourseContainerViewModel + + private var maxWidth: CGFloat { + idiom == .pad ? proxyWidth * 0.5 : proxyWidth * 0.6 + } + + private var assignmentText: String? { var parts: [String] = [] - // Name if let name = sequential.sequentialProgress?.assignmentType, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { parts.append(name) } - // Deadline if let due = sequential.due { parts.append(getAssignmentStatus(for: due)) } - // Progress if let sp = sequential.sequentialProgress, let earned = sp.numPointsEarned, let possible = sp.numPointsPossible, @@ -237,54 +220,99 @@ struct CustomDisclosureGroup: View { return parts.isEmpty ? nil : parts.joined(separator: " - ") } - private func downloadAllSubsections(in chapter: CourseChapter, state: DownloadViewState) { - Task { - var allBlocks: [CourseBlock] = [] - var sequentialsToDownload: [CourseSequential] = [] - for sequential in chapter.childs { - let blocks = await viewModel.collectBlocks( - chapter: chapter, - blockId: sequential.id, - state: state + var body: some View { + VStack(alignment: .leading) { + HStack { + Button( + action: { + guard let courseVertical = sequential.childs.first else { return } + guard let block = courseVertical.childs.first else { + viewModel.router.showGatedContentError(url: courseVertical.webUrl) + return + } + + viewModel.trackSequentialClicked(sequential) + if viewModel.config.uiComponents.courseDropDownNavigationEnabled { + viewModel.router.showCourseUnit( + courseName: viewModel.courseStructure?.displayName ?? "", + blockId: block.id, + courseID: viewModel.courseStructure?.id ?? "", + verticalIndex: 0, + chapters: course.childs, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex, + showVideoNavigation: false, + courseVideoStructure: nil + ) + } else { + viewModel.router.showCourseVerticalView( + courseID: viewModel.courseStructure?.id ?? "", + courseName: viewModel.courseStructure?.displayName ?? "", + title: sequential.displayName, + chapters: course.childs, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + } + }, + label: { + VStack(alignment: .leading) { + HStack { + if sequential.completion == 1 { + CoreAssets.finishedSequence.swiftUIImage + .renderingMode(.template) + .resizable() + .foregroundColor(Theme.Colors.success) + .frame(width: 20, height: 20) + } else { + sequential.type.image + } + Text(sequential.displayName) + .font(Theme.Fonts.titleSmall) + .multilineTextAlignment(.leading) + .lineLimit(1) + .frame( + maxWidth: maxWidth, + alignment: .leading + ) + } + if let assignmentText { + Text(assignmentText) + .font(Theme.Fonts.bodySmall) + .multilineTextAlignment(.leading) + .lineLimit(2) + } + } + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(sequential.displayName) + } ) - if !blocks.isEmpty { - allBlocks.append(contentsOf: blocks) - sequentialsToDownload.append(sequential) + Spacer() + if sequential.due != nil { + CoreAssets.chevronRight.swiftUIImage + .foregroundColor(Theme.Colors.textPrimary) } } - await viewModel.download( - state: state, - blocks: allBlocks, - sequentials: sequentialsToDownload - ) + .padding(.vertical, 4) } } - private func downloadAllButtonState(for chapter: CourseChapter) -> DownloadViewState? { - if canDownloadAllSections(in: chapter) { - var downloads: [DownloadViewState] = [] - for sequential in chapter.childs { - if let state = sequentialDownloadState(sequential) { - downloads.append(state) - } - } - if downloads.contains(.downloading) { - return .downloading - } else if downloads.allSatisfy({ $0 == .finished }) { - return .finished - } else { - return .available - } + private func getAssignmentStatus(for date: Date) -> String { + let calendar = Calendar.current + let today = Date() + + if calendar.isDateInToday(date) { + return CourseLocalization.Course.dueToday + } else if calendar.isDateInTomorrow(date) { + return CourseLocalization.Course.dueTomorrow + } else if let daysUntil = calendar.dateComponents([.day], from: today, to: date).day, daysUntil > 0 { + return CourseLocalization.dueIn(daysUntil) + } else if let daysAgo = calendar.dateComponents([.day], from: date, to: today).day, daysAgo > 0 { + return CourseLocalization.pastDue(daysAgo) + } else { + return "" } - return nil - } - - private func sequentialDownloadState(_ sequential: CourseSequential) -> DownloadViewState? { - return viewModel.sequentialsDownloadState[sequential.id] - } - - private func chapterProgress(for chapter: CourseChapter) -> Double { - return viewModel.chapterProgress(for: chapter) } } diff --git a/Course/Course/Presentation/Unit/CourseNavigationView.swift b/Course/Course/Presentation/Unit/CourseNavigationView.swift index 2d25ec414..c20ae9ac1 100644 --- a/Course/Course/Presentation/Unit/CourseNavigationView.swift +++ b/Course/Course/Presentation/Unit/CourseNavigationView.swift @@ -11,7 +11,6 @@ import Combine struct CourseNavigationView: View { - @ObservedObject private var viewModel: CourseUnitViewModel private let playerStateSubject: CurrentValueSubject diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 9cc7e7fc9..2e1476316 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -15,7 +15,7 @@ import Theme public struct CourseUnitView: View { - @ObservedObject public var viewModel: CourseUnitViewModel + public var viewModel: CourseUnitViewModel @State private var showAlert: Bool = false @State var alertMessage: String? { didSet { @@ -190,12 +190,6 @@ public struct CourseUnitView: View { currentBlock: $currentBlock, block: block ) - .onReceive(NotificationCenter.default.publisher(for: - .onVideoProgressUpdated)) { _ in - Task { - await viewModel.getCourseVideoBlocks() - } - } } } diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index c832771a1..3a79df04d 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -120,8 +120,8 @@ public struct VerticalData: Equatable { } @MainActor -public final class CourseUnitViewModel: ObservableObject { - +@Observable public final class CourseUnitViewModel { + enum LessonAction: Sendable { case next case previous @@ -131,21 +131,22 @@ public final class CourseUnitViewModel: ObservableObject { var verticalIndex: Int var courseName: String - @Published var courseVideosStructure: CourseStructure? - @Published var index: Int = 0 + var courseVideosStructure: CourseStructure? + var index: Int = 0 var previousLesson: String = "" var nextLesson: String = "" - @Published var showError: Bool = false - var errorMessage: String? { - didSet { - showError = errorMessage != nil - } + + var errorMessage: String? + + var showError: Bool { + errorMessage != nil } - @Published public var allVideosForNavigation: [CourseBlock] = [] - @Published public var allVideosFetched = false - @Published public var isVideosForNavigationLoading: Bool = false - @Published var currentVideoIndex: Int? + public var allVideosForNavigation: [CourseBlock] = [] + public var allVideosFetched = false + public var isVideosForNavigationLoading: Bool = false + var currentVideoIndex: Int? + private var videoBlocksTask: Task? var lessonID: String var courseID: String @@ -419,54 +420,65 @@ public final class CourseUnitViewModel: ObservableObject { @MainActor func getCourseVideoBlocks() async { + videoBlocksTask?.cancel() + + if isVideosForNavigationLoading { + return + } + + if !allVideosForNavigation.isEmpty && courseVideosStructure != nil { + return + } isVideosForNavigationLoading = true - defer { - Task { @MainActor in - try? await Task.sleep(for: .seconds(0.2)) - self.isVideosForNavigationLoading = false + videoBlocksTask = Task { @MainActor in + defer { + Task { @MainActor in + try? await Task.sleep(for: .seconds(0.2)) + self.isVideosForNavigationLoading = false + } } - } - if let courseVideosStructure { - do { - let videoFromCourse = await interactor.getCourseVideoBlocks(fullStructure: courseVideosStructure) + if let courseVideosStructure { + do { + let videoFromCourse = await interactor.getCourseVideoBlocks(fullStructure: courseVideosStructure) - allVideosForNavigation = try await interactor.getAllVideosForNavigation( - structure: videoFromCourse - ) + allVideosForNavigation = try await interactor.getAllVideosForNavigation( + structure: videoFromCourse + ) - return + return - } catch { - print("Failed to get all videos for course: \(error.localizedDescription)") + } catch { + } } - } - async let structureTask = getCourseStructure(courseID: courseID) + async let structureTask = getCourseStructure(courseID: courseID) - do { - guard let courseStructure = try await structureTask else { - throw NSError( - domain: "GetCourseBlocks", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "Course structure is nil"] - ) - } + do { + guard let courseStructure = try await structureTask else { + throw NSError( + domain: "GetCourseBlocks", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Course structure is nil"] + ) + } - async let videosTask = interactor.getCourseVideoBlocks(fullStructure: courseStructure) - courseVideosStructure = await videosTask + async let videosTask = interactor.getCourseVideoBlocks(fullStructure: courseStructure) + courseVideosStructure = await videosTask - if let courseVideosStructure { - allVideosForNavigation = try await interactor.getAllVideosForNavigation( - structure: courseVideosStructure - ) - } + if let courseVideosStructure { + allVideosForNavigation = try await interactor.getAllVideosForNavigation( + structure: courseVideosStructure + ) + } - } catch { - print("Failed to load course blocks: \(error.localizedDescription)") - courseVideosStructure = nil + } catch { + courseVideosStructure = nil + } } + + await videoBlocksTask?.value } func createBreadCrumpsForVideoNavigation(video: CourseBlock) -> String { @@ -486,21 +498,21 @@ public final class CourseUnitViewModel: ObservableObject { } } } - .first + .first ?? "" - return breadcrumb ?? "" + return breadcrumb } func handleVideoTap(video: CourseBlock) { - // Find indices for navigation using full course structure guard let chapterIndex = findChapterIndexInFullStructure(video: video), let sequentialIndex = findSequentialIndexInFullStructure(video: video), let verticalIndex = findVerticalIndexInFullStructure(video: video), let courseStructure = courseVideosStructure else { return } + + NotificationCenter.default.post(name: .saveVideoProgressBeforeNavigation, object: nil) - // Track video click analytics analytics.courseVideoClicked( courseId: courseStructure.id, courseName: courseStructure.displayName, @@ -523,22 +535,25 @@ public final class CourseUnitViewModel: ObservableObject { } private func findChapterIndexInFullStructure(video: CourseBlock) -> Int? { - guard let courseStructure = courseVideosStructure else { return nil } + guard let courseStructure = courseVideosStructure else { + return nil + } - // Find the chapter that contains this video in the full structure - return courseStructure.childs.firstIndex { fullChapter in + let index = courseStructure.childs.firstIndex { fullChapter in fullChapter.childs.contains { sequential in sequential.childs.contains { vertical in vertical.childs.contains { $0.id == video.id } } } } + return index } private func findSequentialIndexInFullStructure(video: CourseBlock) -> Int? { - guard let courseStructure = courseVideosStructure else { return nil } + guard let courseStructure = courseVideosStructure else { + return nil + } - // Find the chapter and sequential that contains this video in the full structure for fullChapter in courseStructure.childs { if let sequentialIndex = fullChapter.childs.firstIndex(where: { sequential in sequential.childs.contains { vertical in @@ -552,9 +567,10 @@ public final class CourseUnitViewModel: ObservableObject { } private func findVerticalIndexInFullStructure(video: CourseBlock) -> Int? { - guard let courseStructure = courseVideosStructure else { return nil } + guard let courseStructure = courseVideosStructure else { + return nil + } - // Find the vertical that contains this video in the full structure for fullChapter in courseStructure.childs { for sequential in fullChapter.childs { if let verticalIndex = sequential.childs.firstIndex(where: { vertical in diff --git a/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift b/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift index 8233051d0..05f67d7bf 100644 --- a/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift +++ b/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift @@ -9,7 +9,7 @@ import SwiftUI import Theme struct LessonLineProgressView: View { - @ObservedObject var viewModel: CourseUnitViewModel + var viewModel: CourseUnitViewModel @Environment(\.isHorizontal) private var isHorizontal diff --git a/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift b/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift index 32badbf2a..9573f825f 100644 --- a/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift +++ b/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift @@ -10,7 +10,7 @@ import Core import Theme struct LessonProgressView: View { - @ObservedObject var viewModel: CourseUnitViewModel + @Bindable var viewModel: CourseUnitViewModel @Environment(\.isHorizontal) private var isHorizontal diff --git a/Course/Course/Presentation/Unit/Subviews/VideoNavigationView.swift b/Course/Course/Presentation/Unit/Subviews/VideoNavigationView.swift index c65a50e6b..0760c0a36 100644 --- a/Course/Course/Presentation/Unit/Subviews/VideoNavigationView.swift +++ b/Course/Course/Presentation/Unit/Subviews/VideoNavigationView.swift @@ -5,11 +5,15 @@ import OEXFoundation import Theme struct VideoNavigationView: View { - @ObservedObject var viewModel: CourseUnitViewModel + @Bindable var viewModel: CourseUnitViewModel @Binding var currentBlock: CourseBlock? @State private var uiScrollView: UIScrollView? let block: CourseBlock + private var breadCrumps: String { + viewModel.createBreadCrumpsForVideoNavigation(video: block) + } + var body: some View { if viewModel.isVideosForNavigationLoading { HStack { @@ -36,8 +40,6 @@ struct VideoNavigationView: View { .padding(.bottom, 16) HStack { - let breadCrumps = viewModel.createBreadCrumpsForVideoNavigation(video: block) - VStack(alignment: .leading, spacing: 8) { Text(breadCrumps) .font(Theme.Fonts.bodySmall) @@ -130,7 +132,9 @@ struct VideoNavigationView: View { scrollTo(currentVideo.id) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard let scroll = uiScrollView else { return } + guard let scroll = uiScrollView else { + return + } let newX = max(scroll.contentOffset.x - 20, 0) scroll.setContentOffset(CGPoint(x: newX, y: 0), animated: true) } diff --git a/Course/Course/Presentation/Unit/Subviews/WebView.swift b/Course/Course/Presentation/Unit/Subviews/WebView.swift index 935884a28..30b8e0ae4 100644 --- a/Course/Course/Presentation/Unit/Subviews/WebView.swift +++ b/Course/Course/Presentation/Unit/Subviews/WebView.swift @@ -16,13 +16,31 @@ struct WebView: View { let injections: [WebviewInjection] let blockID: String var roundedBackgroundEnabled: Bool = true + @State private var viewModel: WebUnitViewModel + + init( + url: String, + localUrl: String?, + injections: [WebviewInjection], + blockID: String, + roundedBackgroundEnabled: Bool = true, + viewModel: WebUnitViewModel? = nil + ) { + self.url = url + self.localUrl = localUrl + self.injections = injections + self.blockID = blockID + self.roundedBackgroundEnabled = roundedBackgroundEnabled + let resolvedViewModel = viewModel ?? Container.shared.resolve(WebUnitViewModel.self)! + self._viewModel = State(initialValue: resolvedViewModel) + } var body: some View { VStack(spacing: 0) { WebUnitView( url: url, dataUrl: localUrl, - viewModel: Container.shared.resolve(WebUnitViewModel.self)!, + viewModel: viewModel, connectivity: Connectivity(config: ConfigMock()), injections: injections, blockID: blockID diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 52e2428f1..7f8ece7e3 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -18,8 +18,7 @@ public enum VideoPlayerState: Sendable { public struct EncodedVideoPlayer: View { - @StateObject - private var viewModel: EncodedVideoPlayerViewModel + @Bindable private var viewModel: EncodedVideoPlayerViewModel private var isOnScreen: Bool @@ -45,7 +44,7 @@ public struct EncodedVideoPlayer: View { viewModel: EncodedVideoPlayerViewModel, isOnScreen: Bool ) { - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel self.isOnScreen = isOnScreen } @@ -119,7 +118,7 @@ public struct EncodedVideoPlayer: View { viewModel.controller.player?.allowsExternalPlayback = true viewModel.controller.setNeedsStatusBarAppearanceUpdate() } - .onReceive(viewModel.$currentTime) { currentTime in + .onChange(of: viewModel.currentTime) { _, currentTime in let subtitle = viewModel.findSubtitle(at: Date(milliseconds: currentTime)) subtitleText = subtitle?.text ?? "" } diff --git a/Course/Course/Presentation/Video/PlayerServiceProtocol.swift b/Course/Course/Presentation/Video/PlayerServiceProtocol.swift index acc04b2ac..114a29d73 100644 --- a/Course/Course/Presentation/Video/PlayerServiceProtocol.swift +++ b/Course/Course/Presentation/Video/PlayerServiceProtocol.swift @@ -73,7 +73,7 @@ public final class PlayerService: PlayerServiceProtocol { public func updateVideoProgress(progress: Double) async { await interactor.updateLocalVideoProgress(blockID: blockID, progress: progress) - + NotificationCenter.default.post( name: .onVideoProgressUpdated, object: nil, diff --git a/Course/Course/Presentation/Video/SubtitlesView.swift b/Course/Course/Presentation/Video/SubtitlesView.swift index 1680f2191..37e7e242f 100644 --- a/Course/Course/Presentation/Video/SubtitlesView.swift +++ b/Course/Course/Presentation/Video/SubtitlesView.swift @@ -19,7 +19,6 @@ public struct SubtitlesView: View { @Environment(\.isHorizontal) private var isHorizontal - @ObservedObject private var viewModel: VideoPlayerViewModel private var scrollTo: ((Date) -> Void) = { _ in } diff --git a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift index ec718d570..a235f9078 100644 --- a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift @@ -12,26 +12,27 @@ import _AVKit_SwiftUI import Combine @MainActor -public class VideoPlayerViewModel: ObservableObject { - @Published var pause: Bool = false - @Published var currentTime: Double = 0 - @Published var isLoading: Bool = true - @Published var isLocalProgressApplied: Bool = false +@Observable +public class VideoPlayerViewModel { + var pause: Bool = false + var currentTime: Double = 0 + var isLoading: Bool = true + var isLocalProgressApplied: Bool = false public let connectivity: ConnectivityProtocol private var subtitlesDownloaded: Bool = false - @Published var subtitles: [Subtitle] = [] + var subtitles: [Subtitle] = [] var languages: [SubtitleUrl] - @Published var items: [PickerItem] = [] - @Published var selectedLanguage: String? - - @Published var showError: Bool = false - var errorMessage: String? { - didSet { - showError = errorMessage != nil - } + var items: [PickerItem] = [] + var selectedLanguage: String? + + var errorMessage: String? + + var showError: Bool { + errorMessage != nil } + var isPlayingInPip: Bool { playerHolder.isPlayingInPip } @@ -116,6 +117,13 @@ public class VideoPlayerViewModel: ObservableObject { self?.trackVideoCompleted() } .store(in: &subscription) + + NotificationCenter.default.publisher(for: .saveVideoProgressBeforeNavigation) + .sink { [weak self] _ in + guard let self = self else { return } + self.saveCurrentProgress(duration: self.playerHolder.duration) + } + .store(in: &subscription) } @@ -264,10 +272,8 @@ public class VideoPlayerViewModel: ObservableObject { } public func saveCurrentProgress(duration: TimeInterval) { - Task { let time = currentTime -// let duration = playerHolder.duration if duration > 0 && time > 0 { let progress = min(time / duration, 1.0) diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index cca23484f..e5c65ce57 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -13,8 +13,7 @@ import Swinject public struct YouTubeVideoPlayer: View { - @StateObject - private var viewModel: YouTubeVideoPlayerViewModel + @Bindable private var viewModel: YouTubeVideoPlayerViewModel private var isOnScreen: Bool @State private var showAlert = false @@ -30,7 +29,7 @@ public struct YouTubeVideoPlayer: View { @Environment(\.isHorizontal) private var isHorizontal public init(viewModel: YouTubeVideoPlayerViewModel, isOnScreen: Bool) { - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel self.isOnScreen = isOnScreen } diff --git a/Course/CourseTests/Generated/CourseMocks.generated.swift b/Course/CourseTests/Generated/CourseMocks.generated.swift index 1fdb4fc3e..176f2245b 100644 --- a/Course/CourseTests/Generated/CourseMocks.generated.swift +++ b/Course/CourseTests/Generated/CourseMocks.generated.swift @@ -3303,9 +3303,10 @@ public final class PlayerViewControllerHolderProtocolMock: PlayerViewControllerH public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Sendable { public init() { } - public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false) { + public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false, internetState: InternetState? = nil) { self.isInternetAvaliable = isInternetAvaliable self.isMobileData = isMobileData + self.internetState = internetState } @@ -3321,6 +3322,9 @@ public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Se get { return _internetReachableSubject } set { _internetReachableSubject = newValue } } + + + public var internetState: InternetState? = nil } public final class CourseAnalyticsMock: CourseAnalytics { diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index 3bebbb2c3..103b0c051 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -549,7 +549,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -570,7 +570,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -591,7 +591,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -612,7 +612,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -633,7 +633,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -654,7 +654,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -746,7 +746,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -774,7 +774,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -860,7 +860,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -887,7 +887,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -1037,7 +1037,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1072,7 +1072,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1170,7 +1170,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1263,7 +1263,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1361,7 +1361,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1454,7 +1454,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Dashboard/Dashboard/Presentation/AllCoursesView.swift b/Dashboard/Dashboard/Presentation/AllCoursesView.swift index cfcb97753..46f33b2a7 100644 --- a/Dashboard/Dashboard/Presentation/AllCoursesView.swift +++ b/Dashboard/Dashboard/Presentation/AllCoursesView.swift @@ -13,8 +13,7 @@ import Theme @MainActor public struct AllCoursesView: View { - @ObservedObject - private var viewModel: AllCoursesViewModel + @Bindable private var viewModel: AllCoursesViewModel private let router: DashboardRouter @Environment(\.isHorizontal) private var isHorizontal private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } diff --git a/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift b/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift index dda60e937..11aebd5ae 100644 --- a/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift +++ b/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift @@ -11,16 +11,17 @@ import SwiftUI import Combine @MainActor -public class AllCoursesViewModel: ObservableObject { +@Observable +public class AllCoursesViewModel { var nextPage = 1 var totalPages = 1 - @Published private(set) var fetchInProgress = false - @Published private(set) var refresh = false - @Published var selectedMenu: CategoryOption = .all + private(set) var fetchInProgress = false + private(set) var refresh = false + var selectedMenu: CategoryOption = .all - @Published var myEnrollments: PrimaryEnrollment? - @Published var showError: Bool = false + var myEnrollments: PrimaryEnrollment? + var showError: Bool = false var errorMessage: String? { didSet { withAnimation { diff --git a/Dashboard/Dashboard/Presentation/ListDashboardView.swift b/Dashboard/Dashboard/Presentation/ListDashboardView.swift index fe745ae40..92e6eb417 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardView.swift @@ -25,13 +25,12 @@ public struct ListDashboardView: View { .accessibilityElement(children: .ignore) .accessibilityLabel(DashboardLocalization.Header.courses + DashboardLocalization.Header.welcomeBack) - @StateObject - private var viewModel: ListDashboardViewModel + @Bindable private var viewModel: ListDashboardViewModel private let router: DashboardRouter private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } public init(viewModel: ListDashboardViewModel, router: DashboardRouter) { - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel self.router = router } @@ -145,7 +144,7 @@ public struct ListDashboardView: View { } } } - .onFirstAppear { + .onAppear { Task { await viewModel.getMyCourses(page: 1) } diff --git a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift index 8fc917b00..773859414 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift @@ -11,14 +11,15 @@ import SwiftUI import Combine @MainActor -public class ListDashboardViewModel: ObservableObject { +@Observable +public class ListDashboardViewModel { public var nextPage = 1 public var totalPages = 1 - @Published public private(set) var fetchInProgress = false + public private(set) var fetchInProgress = false - @Published var courses: [CourseItem] = [] - @Published var showError: Bool = false + var courses: [CourseItem] = [] + var showError: Bool = false var errorMessage: String? { didSet { withAnimation { diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift index 170dea778..fe87d430a 100644 --- a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift @@ -13,7 +13,7 @@ import Swinject public struct PrimaryCourseDashboardView: View { - @StateObject private var viewModel: PrimaryCourseDashboardViewModel + private var viewModel: PrimaryCourseDashboardViewModel @ViewBuilder let programView: ProgramView private var openDiscoveryPage: () -> Void private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @@ -25,7 +25,7 @@ public struct PrimaryCourseDashboardView: View { programView: ProgramView, openDiscoveryPage: @escaping () -> Void ) { - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel self.programView = programView self.openDiscoveryPage = openDiscoveryPage } diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift index b4e0a5ac6..43d70ac46 100644 --- a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift @@ -8,19 +8,19 @@ import Foundation import Core import SwiftUI -import Combine @MainActor -public class PrimaryCourseDashboardViewModel: ObservableObject { - +@Observable +public class PrimaryCourseDashboardViewModel { + var nextPage = 1 var totalPages = 1 - @Published public private(set) var fetchInProgress = true - @Published var enrollments: PrimaryEnrollment? - @Published var showError: Bool = false - @Published var updateNeeded: Bool = false + public private(set) var fetchInProgress = true + var enrollments: PrimaryEnrollment? + var showError: Bool = false + var updateNeeded: Bool = false private var updateShowedOnce: Bool = false - + var errorMessage: String? { didSet { withAnimation { @@ -28,14 +28,14 @@ public class PrimaryCourseDashboardViewModel: ObservableObject { } } } - + let connectivity: ConnectivityProtocol private let interactor: DashboardInteractorProtocol let analytics: DashboardAnalytics let config: ConfigProtocol var storage: CoreStorage let router: DashboardRouter - private var cancellables = Set() + @ObservationIgnored nonisolated(unsafe) private var observers: [NSObjectProtocol] = [] private let ipadPageSize = 7 private let iphonePageSize = 5 @@ -54,65 +54,68 @@ public class PrimaryCourseDashboardViewModel: ObservableObject { self.config = config self.storage = storage self.router = router - - let enrollmentPublisher = NotificationCenter.default.publisher(for: .onCourseEnrolled) - let completionPublisher = NotificationCenter.default.publisher(for: .onblockCompletionRequested) - let refreshEnrollmentsPublisher = NotificationCenter.default.publisher(for: .refreshEnrollments) - - enrollmentPublisher - .sink { [weak self] _ in - guard let self = self else { return } - Task { - await self.getEnrollments() - } - } - .store(in: &cancellables) - - completionPublisher - .sink { [weak self] _ in - guard let self = self else { return } - DispatchQueue.main.async { - self.updateEnrollmentsIfNeeded() - } + + let enrollmentObserver = NotificationCenter.default.addObserver( + forName: .onCourseEnrolled, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { + await self.getEnrollments() } - .store(in: &cancellables) - - refreshEnrollmentsPublisher - .sink { [weak self] _ in - guard let self = self else { return } - Task { - await self.getEnrollments() - } + } + + let completionObserver = NotificationCenter.default.addObserver( + forName: .onblockCompletionRequested, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + self.updateEnrollmentsIfNeeded() + } + + let refreshObserver = NotificationCenter.default.addObserver( + forName: .refreshEnrollments, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { + await self.getEnrollments() } - .store(in: &cancellables) + } + + observers.append(contentsOf: [enrollmentObserver, completionObserver, refreshObserver]) } func setupNotifications() { - NotificationCenter.default.publisher(for: .onActualVersionReceived) - .receive(on: DispatchQueue.main) - .sink { [weak self] notification in - if let latestVersion = notification.object as? String { - // Save the latest version to storage - self?.storage.latestAvailableAppVersion = latestVersion - - if let info = Bundle.main.infoDictionary { - guard let currentVersion = info["CFBundleShortVersionString"] as? String, - let self else { return } - if currentVersion.isAppVersionGreater(than: latestVersion) == false - && currentVersion != latestVersion { - if self.updateShowedOnce == false { - DispatchQueue.main.async { - self.router.showUpdateRecomendedView() - } - self.updateShowedOnce = true - } + let versionObserver = NotificationCenter.default.addObserver( + forName: .onActualVersionReceived, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self = self else { return } + if let latestVersion = notification.object as? String { + // Save the latest version to storage + self.storage.latestAvailableAppVersion = latestVersion + + if let info = Bundle.main.infoDictionary { + guard let currentVersion = info["CFBundleShortVersionString"] as? String else { return } + if currentVersion.isAppVersionGreater(than: latestVersion) == false + && currentVersion != latestVersion { + if self.updateShowedOnce == false { + self.router.showUpdateRecomendedView() + self.updateShowedOnce = true } } } - }.store(in: &cancellables) + } + } + observers.append(versionObserver) } - private func updateEnrollmentsIfNeeded() { + func updateEnrollmentsIfNeeded() { guard updateNeeded else { return } Task { await getEnrollments() @@ -148,4 +151,8 @@ public class PrimaryCourseDashboardViewModel: ObservableObject { func trackDashboardCourseClicked(courseID: String, courseName: String) { analytics.dashboardCourseClicked(courseID: courseID, courseName: courseName) } + + deinit { + observers.forEach { NotificationCenter.default.removeObserver($0) } + } } diff --git a/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift b/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift index c36c4bd2e..6ca250c64 100644 --- a/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift +++ b/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift @@ -1548,9 +1548,10 @@ public final class DashboardInteractorProtocolMock: DashboardInteractorProtocol, public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Sendable { public init() { } - public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false) { + public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false, internetState: InternetState? = nil) { self.isInternetAvaliable = isInternetAvaliable self.isMobileData = isMobileData + self.internetState = internetState } @@ -1566,6 +1567,9 @@ public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Se get { return _internetReachableSubject } set { _internetReachableSubject = newValue } } + + + public var internetState: InternetState? = nil } public final class DownloadManagerProtocolMock: DownloadManagerProtocol, @unchecked Sendable { diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 5d5f494d5..4e2ca91fe 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -625,7 +625,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -646,7 +646,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -667,7 +667,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -688,7 +688,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -709,7 +709,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -730,7 +730,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -823,7 +823,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -851,7 +851,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -938,7 +938,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -965,7 +965,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -1116,7 +1116,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1152,7 +1152,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1251,7 +1251,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1351,7 +1351,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1445,7 +1445,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1538,7 +1538,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index 94b6b79d4..dbc2a35af 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -13,11 +13,14 @@ import WebKit import Theme public struct CourseDetailsView: View { - - @ObservedObject private var viewModel: CourseDetailsViewModel + @Environment(\.colorScheme) var colorScheme @Environment(\.isHorizontal) var isHorizontal - @State private var isOverviewRendering = true + + @State var isProcessing: Bool = true + + private var viewModel: CourseDetailsViewModel + private var title: String private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } private var courseID: String @@ -121,13 +124,13 @@ public struct CourseDetailsView: View { html: courseDetails.overviewHTML, type: .discovery, screenWidth: proxy.size.width - 48), - processing: { rendering in - isOverviewRendering = rendering + processing: { isProcessing in + self.isProcessing = isProcessing } ) .padding(.horizontal, 16) - if isOverviewRendering { + if isProcessing { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 20) .frame(maxWidth: .infinity) diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsViewModel.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsViewModel.swift index 6f4aa814c..69442e710 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsViewModel.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsViewModel.swift @@ -16,12 +16,13 @@ public enum CourseState { } @MainActor -public final class CourseDetailsViewModel: ObservableObject { +@Observable +public final class CourseDetailsViewModel { - @Published var courseDetails: CourseDetails? - @Published private(set) var isShowProgress = false - @Published var showError: Bool = false - @Published var isHorisontal: Bool = false + var courseDetails: CourseDetails? + private(set) var isShowProgress = false + var showError: Bool = false + var isHorisontal: Bool = false var errorMessage: String? { didSet { withAnimation { @@ -63,6 +64,7 @@ public final class CourseDetailsViewModel: ObservableObject { @MainActor func getCourseDetail(courseID: String, withProgress: Bool = true) async { isShowProgress = withProgress + do { if connectivity.isInternetAvaliable { courseDetails = try await interactor.getCourseDetails(courseID: courseID) diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift index fd5a3a398..1b65966b7 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift @@ -11,12 +11,9 @@ import OEXFoundation import Theme public struct DiscoveryView: View { - - @StateObject - private var viewModel: DiscoveryViewModel + + @Bindable private var viewModel: DiscoveryViewModel private var router: DiscoveryRouter - @State private var searchQuery: String = "" - @State private var isRefreshing: Bool = false private var sourceScreen: LogistrationSourceScreen @@ -42,9 +39,9 @@ public struct DiscoveryView: View { searchQuery: String? = nil, sourceScreen: LogistrationSourceScreen = .default ) { - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel self.router = router - self._searchQuery = State(initialValue: searchQuery ?? "") + viewModel.searchQuery = searchQuery ?? "" self.sourceScreen = sourceScreen } @@ -68,7 +65,7 @@ public struct DiscoveryView: View { Spacer() } .onTapGesture { - router.showDiscoverySearch(searchQuery: searchQuery) + router.showDiscoverySearch(searchQuery: viewModel.searchQuery) viewModel.discoverySearchBarClicked() } .frame(minHeight: 48) @@ -82,7 +79,7 @@ public struct DiscoveryView: View { .stroke(lineWidth: 1) .fill(Theme.Colors.textInputUnfocusedStroke) ).onTapGesture { - router.showDiscoverySearch(searchQuery: searchQuery) + router.showDiscoverySearch(searchQuery: viewModel.searchQuery) viewModel.discoverySearchBarClicked() } .padding(.top, 11.5) @@ -132,9 +129,11 @@ public struct DiscoveryView: View { VStack(alignment: .center) { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 20) - }.frame(maxWidth: .infinity, + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } + VStack {}.frame(height: 40) } .frameLimit(width: proxy.size.width) @@ -190,9 +189,9 @@ public struct DiscoveryView: View { } .navigationBarHidden(sourceScreen != .startup) .onFirstAppear { - if !(searchQuery.isEmpty) { - router.showDiscoverySearch(searchQuery: searchQuery) - searchQuery = "" + if !(viewModel.searchQuery.isEmpty) { + router.showDiscoverySearch(searchQuery: viewModel.searchQuery) + viewModel.searchQuery = "" } Task { await viewModel.discovery(page: 1) diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift index 509ddbd6f..deac9f8f8 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift @@ -10,14 +10,17 @@ import Core import SwiftUI @MainActor -public final class DiscoveryViewModel: ObservableObject { - +@Observable +public final class DiscoveryViewModel { + + var searchQuery = "" var nextPage = 1 var totalPages = 1 + var isRefreshing = false private(set) var fetchInProgress = false - @Published var courses: [CourseItem] = [] - @Published var showError: Bool = false + var courses: [CourseItem] = [] + var showError: Bool = false var userloggedIn: Bool { return !(storage.user?.username?.isEmpty ?? true) @@ -82,6 +85,7 @@ public final class DiscoveryViewModel: ObservableObject { await courses += try interactor.discovery(page: page) } self.nextPage += 1 + if !courses.isEmpty { totalPages = courses[0].numPages } diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift index 9aa8f5702..7141730c6 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift @@ -14,11 +14,10 @@ public struct SearchView: View { @FocusState private var focused: Bool - - @ObservedObject + + @Bindable private var viewModel: SearchViewModel - @State private var animated: Bool = false - + public init(viewModel: SearchViewModel, searchQuery: String? = nil) { self.viewModel = viewModel self.viewModel.searchText = searchQuery ?? "" @@ -102,8 +101,8 @@ public struct SearchView: View { searchHeader(viewModel: viewModel) .padding(.horizontal, 24) .padding(.bottom, 20) - .offset(y: animated ? 0 : 50) - .opacity(animated ? 1 : 0) + .offset(y: viewModel.animated ? 0 : 50) + .opacity(viewModel.animated ? 1 : 0) Spacer() } .padding(.leading, 10) @@ -170,7 +169,7 @@ public struct SearchView: View { .onAppear { DispatchQueue.main.asyncAfter(deadline: .now()) { withAnimation(.easeIn(duration: 0.3)) { - animated = true + viewModel.animated = true } } } diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift index 05c9b7ac4..86865dc64 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift @@ -11,18 +11,28 @@ import SwiftUI import Combine @MainActor -public final class SearchViewModel: ObservableObject { +@Observable public final class SearchViewModel { var nextPage = 1 var totalPages = 1 - @Published private(set) var fetchInProgress = false - @Published var isSearchActive = false - @Published var searchResults: [CourseItem] = [] - @Published var showError: Bool = false - @Published var searchText: String = "" + + private(set) var fetchInProgress = false + var isSearchActive = false + var animated: Bool = false + var searchResults: [CourseItem] = [] + var showError: Bool = false + + var searchText: String = "" { + didSet { + handleSearchTextChange(oldValue: oldValue, newValue: searchText) + } + } + private var prevQuery: String = "" private var subscription = Set() private let debounce: Debounce - + + @ObservationIgnored private var searchTask: Task? + var errorMessage: String? { didSet { withAnimation { @@ -50,31 +60,31 @@ public final class SearchViewModel: ObservableObject { self.analytics = analytics self.storage = storage self.debounce = debounce - - $searchText - .debounce(for: debounce.dueTime, scheduler: debounce.scheduler) - .removeDuplicates() - .sink { str in - let term = str - .trimmingCharacters(in: .whitespaces) - Task.detached(priority: .high) { - if !term.isEmpty { - if await term == self.prevQuery { return } - await MainActor.run { - self.nextPage = 1 - } - await self.search(page: self.nextPage, searchTerm: str) - } else { - await MainActor.run { - self.prevQuery = "" - self.searchResults.removeAll() - } - } - } + } + + private func handleSearchTextChange(oldValue: String, newValue: String) { + searchTask?.cancel() + + searchTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(debounce.dueTimeInMilliseconds)) + + guard !Task.isCancelled else { return } + + let term = newValue.trimmingCharacters(in: .whitespaces) + + if !term.isEmpty { + if term == prevQuery { return } + + nextPage = 1 + + await search(page: nextPage, searchTerm: newValue) + } else { + prevQuery = "" + searchResults.removeAll() } - .store(in: &subscription) + } } - + @MainActor public func searchCourses(index: Int, searchTerm: String) async { if !fetchInProgress { diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift index 2b7b48199..a7e7ee87d 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift @@ -29,10 +29,9 @@ public enum DiscoveryWebviewType: Equatable { } public struct DiscoveryWebview: View { - @State private var searchQuery: String = "" - @State private var isLoading: Bool = true - - @StateObject private var viewModel: DiscoveryWebviewViewModel + + @Bindable private var viewModel: DiscoveryWebviewViewModel + private var router: DiscoveryRouter private var discoveryType: DiscoveryWebviewType public var pathID: String @@ -40,9 +39,9 @@ public struct DiscoveryWebview: View { private var URLString: String { switch discoveryType { case .discovery: - if !searchQuery.isEmpty { + if !viewModel.searchQuery.isEmpty { let baseURL = viewModel.config.discovery.webview.baseURL ?? "" - return buildQuery(baseURL: baseURL, params: ["q": searchQuery]) + return buildQuery(baseURL: baseURL, params: ["q": viewModel.searchQuery]) } return viewModel.config.discovery.webview.baseURL ?? "" @@ -82,9 +81,9 @@ public struct DiscoveryWebview: View { discoveryType: DiscoveryWebviewType = .discovery, pathID: String = "" ) { - self._viewModel = .init(wrappedValue: viewModel) + self.viewModel = viewModel self.router = router - self._searchQuery = State(initialValue: searchQuery ?? "") + viewModel.searchQuery = searchQuery ?? "" self.discoveryType = discoveryType self.pathID = pathID } @@ -99,7 +98,7 @@ public struct DiscoveryWebview: View { baseURL: "", openFile: {_ in} ), - isLoading: $isLoading, + isLoading: $viewModel.isLoading, refreshCookies: {}, navigationDelegate: viewModel, connectivity: viewModel.connectivity, @@ -107,7 +106,7 @@ public struct DiscoveryWebview: View { ) .accessibilityIdentifier("discovery_webview") - if isLoading || viewModel.showProgress { + if viewModel.isLoading || viewModel.showProgress { HStack(alignment: .center) { ProgressBar( size: 40, @@ -133,7 +132,7 @@ public struct DiscoveryWebview: View { } } - if !viewModel.userloggedIn, !isLoading { + if !viewModel.userloggedIn, !viewModel.isLoading { LogistrationBottomView( ssoEnabled: viewModel.config.uiComponents.samlSSOLoginEnabled ) { buttonAction in diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index 3cbaf5d77..6bcc26029 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -11,11 +11,15 @@ import SwiftUI import WebKit @MainActor -public final class DiscoveryWebviewViewModel: ObservableObject { - @Published var courseDetails: CourseDetails? - @Published private(set) var showProgress = false - @Published var showError: Bool = false - @Published var webViewError: Bool = false +@Observable +public final class DiscoveryWebviewViewModel { + + var courseDetails: CourseDetails? + private(set) var showProgress = false + var showError: Bool = false + var webViewError: Bool = false + var searchQuery: String = "" + var isLoading: Bool = true var errorMessage: String? { didSet { diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift index 7ceb6f74d..4c2458bb6 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift @@ -17,9 +17,8 @@ public enum ProgramViewType: String, Equatable { } public struct ProgramWebviewView: View { - @State private var isLoading: Bool = true - - @StateObject private var viewModel: ProgramWebviewViewModel + @Bindable private var viewModel: ProgramWebviewViewModel + private var router: DiscoveryRouter private var viewType: ProgramViewType public var pathID: String @@ -43,7 +42,7 @@ public struct ProgramWebviewView: View { viewType: ProgramViewType = .program, pathID: String = "" ) { - self._viewModel = .init(wrappedValue: viewModel) + self.viewModel = viewModel self.router = router self.viewType = viewType self.pathID = pathID @@ -60,7 +59,7 @@ public struct ProgramWebviewView: View { openFile: {_ in}, injections: [.colorInversionCss] ), - isLoading: $isLoading, + isLoading: $viewModel.webViewIsLoading, refreshCookies: { await viewModel.updateCookies( force: true @@ -73,7 +72,7 @@ public struct ProgramWebviewView: View { .accessibilityIdentifier("program_webview") let shouldShowProgress = ( - isLoading || + viewModel.webViewIsLoading || viewModel.showProgress || viewModel.updatingCookies ) diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift index b575e8480..6d15e9388 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift @@ -10,13 +10,16 @@ import Core import SwiftUI import WebKit -public final class ProgramWebviewViewModel: ObservableObject, WebviewCookiesUpdateProtocol { - @Published var courseDetails: CourseDetails? - @Published private(set) var showProgress = false - @Published var showError: Bool = false - @Published var webViewError: Bool = false - @Published public var updatingCookies: Bool = false - @Published public var cookiesReady: Bool = false +@Observable +public final class ProgramWebviewViewModel: WebviewCookiesUpdateProtocol { + var courseDetails: CourseDetails? + private(set) var showProgress = false + var showError: Bool = false + var webViewError: Bool = false + var webViewIsLoading: Bool = false + + public var updatingCookies: Bool = false + public var cookiesReady: Bool = false public var errorMessage: String? { didSet { diff --git a/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift b/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift index 058d227fc..ab3d0118a 100644 --- a/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift +++ b/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift @@ -2207,9 +2207,10 @@ public final class DiscoveryRepositoryProtocolMock: DiscoveryRepositoryProtocol, public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Sendable { public init() { } - public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false) { + public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false, internetState: InternetState? = nil) { self.isInternetAvaliable = isInternetAvaliable self.isMobileData = isMobileData + self.internetState = internetState } @@ -2225,6 +2226,9 @@ public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Se get { return _internetReachableSubject } set { _internetReachableSubject = newValue } } + + + public var internetState: InternetState? = nil } public final class DownloadManagerProtocolMock: DownloadManagerProtocol, @unchecked Sendable { diff --git a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift index f8d805f3c..14c506122 100644 --- a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift @@ -73,9 +73,8 @@ final class SearchViewModelTests: XCTestCase { viewModel.searchText = "Test" - // Wait for debounce + next event loop iteration - try await Task.sleep(nanoseconds: UInt64(0.5 * Double(NSEC_PER_SEC))) - await Task.yield() + // Wait for search to complete + try await Task.sleep(for: .milliseconds(10)) XCTAssertTrue(interactor.searchCallCount > 0) XCTAssertTrue(analytics.discoveryCoursesSearchCallCount > 0) @@ -128,10 +127,8 @@ final class SearchViewModelTests: XCTestCase { viewModel.searchText = "Test" - // Wait for debounce + next event loop iteration - try await Task.sleep(nanoseconds: UInt64(0.5 * Double(NSEC_PER_SEC))) - await Task.yield() - + // Wait for search to complete + try await Task.sleep(for: .milliseconds(10)) XCTAssertEqual(interactor.searchCallCount, 1) @@ -160,9 +157,8 @@ final class SearchViewModelTests: XCTestCase { viewModel.searchText = "Test" - // Wait for debounce + next event loop iteration - try await Task.sleep(nanoseconds: UInt64(0.5 * Double(NSEC_PER_SEC))) - await Task.yield() + // Wait for search to complete + try await Task.sleep(for: .milliseconds(10)) XCTAssertEqual(interactor.searchCallCount, 1) diff --git a/Discussion/Discussion.xcodeproj/project.pbxproj b/Discussion/Discussion.xcodeproj/project.pbxproj index a56151468..63e6ea4c4 100644 --- a/Discussion/Discussion.xcodeproj/project.pbxproj +++ b/Discussion/Discussion.xcodeproj/project.pbxproj @@ -950,7 +950,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -985,7 +985,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1083,7 +1083,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1182,7 +1182,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1275,7 +1275,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1367,7 +1367,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1395,7 +1395,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1416,7 +1416,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1437,7 +1437,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1458,7 +1458,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1479,7 +1479,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1500,7 +1500,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1591,7 +1591,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1620,7 +1620,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1705,7 +1705,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1733,7 +1733,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; diff --git a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift index 5ed34cff0..94559da56 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift @@ -12,18 +12,19 @@ import Combine import Swinject @MainActor +@Observable public class BaseResponsesViewModel { - @Published public var postComments: Post? - @Published public var isShowProgress = false - @Published public var showError = false - @Published public var showAlert = false - @Published public var addCommentsIsVisible = true + public var postComments: Post? + public var isShowProgress = false + public var showError = false + public var showAlert = false + public var addCommentsIsVisible = true internal var comments: [UserComment] = [] public var nextPage = 2 public var totalPages = 1 - @Published public var itemsCount = 0 + public var itemsCount = 0 public var fetchInProgress = false var errorMessage: String? { diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 7f56caeeb..2b1d47f6f 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -18,7 +18,7 @@ public struct ResponsesView: View { private let commentID: String private let parentComment: Post - @ObservedObject private var viewModel: ResponsesViewModel + private var viewModel: ResponsesViewModel @State private var isShowProgress: Bool = true public init( diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift index 4a91a88c4..090798393 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift @@ -10,9 +10,10 @@ import SwiftUI import Core import Combine -public final class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { +@Observable +public final class ResponsesViewModel: BaseResponsesViewModel { - @Published var scrollTrigger: Bool = false + var scrollTrigger: Bool = false private let threadStateSubject: CurrentValueSubject public var isBlackedOut: Bool = false private let analytics: DiscussionAnalytics? diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadPostState.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadPostState.swift index 671db6689..4e48288f5 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadPostState.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadPostState.swift @@ -7,7 +7,7 @@ import Foundation -public enum ThreadPostState { +public enum ThreadPostState: Sendable { case voted(id: String, voted: Bool, votesCount: Int) case flagged(id: String, flagged: Bool) case postAdded(id: String) diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 96c27e049..26ed081d3 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -16,7 +16,7 @@ public struct ThreadView: View { public let thread: UserThread private var onBackTapped: (() -> Void) = {} - @ObservedObject private var viewModel: ThreadViewModel + private var viewModel: ThreadViewModel @Environment(\.colorScheme) var colorScheme @State private var isShowProgress: Bool = true @State private var commentText: String = "" @@ -267,6 +267,7 @@ public struct ThreadView: View { .onDisappear { onBackTapped() viewModel.sendUpdateUnreadState() + viewModel.cleanup() } .edgesIgnoringSafeArea(.bottom) .background( diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift index d33842aa6..35d9ca87f 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift @@ -9,13 +9,14 @@ import Foundation import Combine import Core -public final class ThreadViewModel: BaseResponsesViewModel, ObservableObject { +@Observable +public final class ThreadViewModel: BaseResponsesViewModel { - @Published var scrollTrigger: Bool = false - - internal let threadStateSubject = CurrentValueSubject(nil) - private var cancellable: AnyCancellable? - private let postStateSubject: CurrentValueSubject + var scrollTrigger: Bool = false + + @ObservationIgnored internal let threadStateSubject = CurrentValueSubject(nil) + @ObservationIgnored private let postStateSubject: CurrentValueSubject + nonisolated(unsafe) private var observationTask: Task? public var isBlackedOut: Bool = false private let analytics: DiscussionAnalytics? @@ -29,13 +30,15 @@ public final class ThreadViewModel: BaseResponsesViewModel, ObservableObject { ) { self.postStateSubject = postStateSubject self.analytics = analytics - + super.init(interactor: interactor, router: router, config: config, storage: storage, analytics: analytics) - - cancellable = threadStateSubject - .receive(on: RunLoop.main) - .sink(receiveValue: { [weak self] state in - guard let self, let state else { return } + + observationTask = Task { @MainActor in + for await state in threadStateSubject.values { + if Task.isCancelled { + break + } + guard let state = state else { continue } switch state { case let .voted(id, voted, votesCount): self.updateThreadLikeState(id: id, voted: voted, votesCount: votesCount) @@ -45,7 +48,16 @@ public final class ThreadViewModel: BaseResponsesViewModel, ObservableObject { self.updateThreadPostsCountState(id: id) self.sendPostRepliesCountState() } - }) + } + } + } + + deinit { + observationTask?.cancel() + } + + public func cleanup() { + observationTask?.cancel() } func generateComments(comments: [UserComment], thread: UserThread) -> Post { diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift index 182e34945..c4ea7b698 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift @@ -20,9 +20,8 @@ public struct CreateNewThreadView: View { private var courseID: String @Environment(\.colorScheme) var colorScheme - - @ObservedObject - private var viewModel: CreateNewThreadViewModel + + @Bindable private var viewModel: CreateNewThreadViewModel public init( viewModel: CreateNewThreadViewModel, diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift index 688d0c62e..b7db84fb7 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift @@ -9,12 +9,13 @@ import Core import SwiftUI @MainActor -public class CreateNewThreadViewModel: ObservableObject { +@Observable +public class CreateNewThreadViewModel { - @Published private(set) var isShowProgress = false - @Published var showError: Bool = false - @Published var allTopics: [CoursewareTopics] = [] - @Published var selectedTopic: String = "" + private(set) var isShowProgress = false + var showError: Bool = false + var allTopics: [CoursewareTopics] = [] + var selectedTopic: String = "" public var topics: Topics? var errorMessage: String? { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index 622bc60b3..ff09a8365 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -15,10 +15,10 @@ public struct DiscussionSearchTopicsView: View { @FocusState private var focused: Bool - @ObservedObject private var viewModel: DiscussionSearchTopicsViewModel + @Bindable private var viewModel: DiscussionSearchTopicsViewModel @State private var animated: Bool = false - - public init(viewModel: DiscussionSearchTopicsViewModel) { + + public init(viewModel: DiscussionSearchTopicsViewModel) { self.viewModel = viewModel } @@ -162,7 +162,7 @@ public struct DiscussionSearchTopicsView: View { } } - private func searchHeader(viewModel: DiscussionSearchTopicsViewModel) -> some View { + private func searchHeader(viewModel: DiscussionSearchTopicsViewModel) -> some View { return VStack(alignment: .leading) { Text(DiscussionLocalization.Search.title) .font(Theme.Fonts.displaySmall) @@ -172,8 +172,8 @@ public struct DiscussionSearchTopicsView: View { .foregroundColor(Theme.Colors.textPrimary) }.listRowBackground(Color.clear) } - - private func searchDescription(viewModel: DiscussionSearchTopicsViewModel) -> String { + + private func searchDescription(viewModel: DiscussionSearchTopicsViewModel) -> String { let searchEmptyDescription = DiscussionLocalization.Search.emptyDescription let searchDescription = DiscussionLocalization.searchResultsDescription( viewModel.searchResults.isEmpty @@ -198,8 +198,7 @@ struct DiscussionSearchTopicsView_Previews: PreviewProvider { courseID: "123", interactor: DiscussionInteractor.mock, storage: CoreStorageMock(), - router: DiscussionRouterPreviewMock(), - debounce: .searchDebounce + router: DiscussionRouterPreviewMock() ) DiscussionSearchTopicsView(viewModel: vm) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift index 641f768c6..868771868 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift @@ -8,27 +8,33 @@ import Foundation import SwiftUI import Core -@preconcurrency import Combine +import Combine @MainActor -public final class DiscussionSearchTopicsViewModel: ObservableObject { - - @Published private(set) var fetchInProgress = false - @Published var isSearchActive = false - @Published var searchResults: [DiscussionPost] = [] - @Published var showError: Bool = false - @Published var searchText: String = "" - +@Observable +public final class DiscussionSearchTopicsViewModel { + + private(set) var fetchInProgress = false + var isSearchActive = false + var searchResults: [DiscussionPost] = [] + var showError: Bool = false + var searchText: String = "" { + didSet { + handleSearchTextChange(searchText) + } + } + private var prevQuery: String = "" private var courseID: String - private var subscription = Set() - @Published private var threads: [UserThread] = [] - + private var threads: [UserThread] = [] + private var nextPage = 1 private var totalPages = 1 - - internal let postStateSubject = CurrentValueSubject(nil) - private var cancellable: AnyCancellable? + + nonisolated(unsafe) private var searchTask: Task? + nonisolated(unsafe) private var observationTask: Task? + // Keep CurrentValueSubject for now since it's passed to router + @ObservationIgnored internal let postStateSubject = CurrentValueSubject(nil) var errorMessage: String? { didSet { @@ -41,25 +47,25 @@ public final class DiscussionSearchTopicsViewModel: ObservableObje let router: DiscussionRouter private let interactor: DiscussionInteractorProtocol private let storage: CoreStorage - private let debounce: Debounce - + private let debounceInterval: TimeInterval + public init( courseID: String, interactor: DiscussionInteractorProtocol, storage: CoreStorage, router: DiscussionRouter, - debounce: Debounce + debounceInterval: TimeInterval = 0.8 ) { self.courseID = courseID self.interactor = interactor self.storage = storage self.router = router - self.debounce = debounce + self.debounceInterval = debounceInterval - cancellable = postStateSubject - .receive(on: RunLoop.main) - .sink(receiveValue: { [weak self] state in - guard let self, let state else { return } + // Setup observer for postStateSubject + observationTask = Task { + for await state in postStateSubject.values { + guard let state = state else { continue } switch state { case let .followed(id, followed): self.updatePostFollowedState(id: id, followed: followed) @@ -72,33 +78,38 @@ public final class DiscussionSearchTopicsViewModel: ObservableObje case let .reported(id, reported): self.updatePostReportedState(id: id, reported: reported) } - }) - - $searchText - .debounce(for: debounce.dueTime, scheduler: debounce.scheduler) - .removeDuplicates() - .sink { [weak self] str in - guard let self else { return } - let term = str - .trimmingCharacters(in: .whitespaces) - Task.detached(priority: .high) { - if !term.isEmpty { - if await term == self.prevQuery { - return - } - await MainActor.run { - self.nextPage = 1 - } - await self.search(page: self.nextPage, searchTerm: str) - } else { - await MainActor.run { - self.prevQuery = "" - self.searchResults.removeAll() - } - } + } + } + } + + deinit { + searchTask?.cancel() + observationTask?.cancel() + } + + private func handleSearchTextChange(_ text: String) { + // Cancel previous search task + searchTask?.cancel() + + // Start new debounced search + searchTask = Task { + try? await Task.sleep(nanoseconds: UInt64(debounceInterval * 1_000_000_000)) + + guard !Task.isCancelled else { return } + + let term = text.trimmingCharacters(in: .whitespaces) + + if !term.isEmpty { + if term == prevQuery { + return } + nextPage = 1 + await search(page: nextPage, searchTerm: text) + } else { + prevQuery = "" + searchResults.removeAll() } - .store(in: &subscription) + } } func searchCourses(index: Int, searchTerm: String) async { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 20a637c6c..e7029019c 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -12,7 +12,7 @@ import Theme public struct DiscussionTopicsView: View { - @StateObject private var viewModel: DiscussionTopicsViewModel + @Bindable private var viewModel: DiscussionTopicsViewModel private let router: DiscussionRouter private let courseID: String @Binding private var coordinate: CGFloat @@ -28,7 +28,7 @@ public struct DiscussionTopicsView: View { viewModel: DiscussionTopicsViewModel, router: DiscussionRouter ) { - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel self.courseID = courseID self._coordinate = coordinate self._collapsed = collapsed @@ -48,12 +48,13 @@ public struct DiscussionTopicsView: View { viewHeight: $viewHeight ) RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) + // MARK: - Search fake field if viewModel.isBlackedOut { bannerDiscussionsDisabled } - if let topics = viewModel.discussionTopics, topics.count > 0 { + if !viewModel.discussionTopics.isEmpty { HStack(spacing: 11) { Image(systemName: "magnifyingglass") .foregroundColor(Theme.Colors.textInputTextColor) @@ -91,7 +92,7 @@ public struct DiscussionTopicsView: View { VStack { ZStack(alignment: .top) { VStack { - if let topics = viewModel.discussionTopics { + if !viewModel.discussionTopics.isEmpty { HStack { Text(DiscussionLocalization.Topics.mainCategories) .font(Theme.Fonts.titleMedium) @@ -101,7 +102,7 @@ public struct DiscussionTopicsView: View { Spacer() } HStack(spacing: 8) { - if let allTopics = topics.first(where: { + if let allTopics = viewModel.discussionTopics.first(where: { $0.name == DiscussionLocalization.Topics.allPosts }) { Button(action: { allTopics.action() @@ -119,7 +120,7 @@ public struct DiscussionTopicsView: View { }).cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground) .padding(.trailing, -20) } - if let followed = topics.first(where: { + if let followed = viewModel.discussionTopics.first(where: { $0.name == DiscussionLocalization.Topics.postImFollowing}) { Button(action: { followed.action() @@ -139,7 +140,8 @@ public struct DiscussionTopicsView: View { } }.padding(.bottom, 16) - ForEach(Array(topics.enumerated()), id: \.offset) { _, topic in + ForEach(Array(viewModel.discussionTopics.enumerated()), + id: \.offset) { _, topic in if topic.name != DiscussionLocalization.Topics.allPosts && topic.name != DiscussionLocalization.Topics.postImFollowing { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index 3a5dfffd0..5909d8473 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -11,15 +11,16 @@ import Core // swiftlint:disable function_body_length @MainActor -public final class DiscussionTopicsViewModel: ObservableObject { +@Observable +public final class DiscussionTopicsViewModel { - @Published var topics: Topics? - @Published var isShowProgress = true - @Published var isShowRefresh = false - @Published var showError: Bool = false - @Published var discussionTopics: [DiscussionTopic]? - @Published var courseID: String = "" - @Published private(set) var isBlackedOut: Bool = false + var topics: Topics? + var isShowProgress = true + var isShowRefresh = false + var showError: Bool = false + var discussionTopics: [DiscussionTopic] = [] + var courseID: String = "" + private(set) var isBlackedOut: Bool = false let title: String var errorMessage: String? { @@ -179,12 +180,13 @@ public final class DiscussionTopicsViewModel: ObservableObject { topics = try await interactor.getTopics(courseID: courseID) discussionTopics = generateTopics(topics: topics) + isShowProgress = false isShowRefresh = false } catch { isShowProgress = false isShowRefresh = false - discussionTopics = nil + discussionTopics = [] } } } diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 3985d59b8..899356b6b 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -12,7 +12,7 @@ import Theme public struct PostsView: View { - @ObservedObject private var viewModel: PostsViewModel + private var viewModel: PostsViewModel @State private var showFilterSheet = false @State private var showSortSheet = false private let router: DiscussionRouter @@ -244,6 +244,9 @@ public struct PostsView: View { Theme.Colors.background .ignoresSafeArea() ) + .onDisappear { + viewModel.cleanup() + } } } diff --git a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift index 2ac959a93..491fa3e90 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift @@ -11,21 +11,22 @@ import Combine import Core @MainActor -public final class PostsViewModel: ObservableObject { +@Observable +public final class PostsViewModel { public var nextPage = 1 public var totalPages = 1 - @Published public private(set) var fetchInProgress = false + public private(set) var fetchInProgress = false public enum ButtonType { case sort case filter } - @Published private(set) var isShowProgress = false - @Published var showError: Bool = false - @Published var filteredPosts: [DiscussionPost] = [] - @Published var filterTitle: ThreadsFilter = .allThreads { + private(set) var isShowProgress = false + var showError: Bool = false + var filteredPosts: [DiscussionPost] = [] + var filterTitle: ThreadsFilter = .allThreads { willSet { if courseID != nil { resetPosts() @@ -35,7 +36,8 @@ public final class PostsViewModel: ObservableObject { } } } - @Published var sortTitle: SortType = .recentActivity { + + var sortTitle: SortType = .recentActivity { willSet { if courseID != nil { resetPosts() @@ -63,7 +65,7 @@ public final class PostsViewModel: ObservableObject { } public var courseID: String? - @Published var isBlackedOut: Bool? + var isBlackedOut: Bool? var errorMessage: String? { didSet { @@ -82,8 +84,8 @@ public final class PostsViewModel: ObservableObject { private let router: DiscussionRouter private let config: ConfigProtocol private let storage: CoreStorage - internal let postStateSubject = CurrentValueSubject(nil) - private var cancellable: AnyCancellable? + @ObservationIgnored internal let postStateSubject = CurrentValueSubject(nil) + nonisolated(unsafe) private var observationTask: Task? public init( interactor: DiscussionInteractorProtocol, @@ -95,11 +97,13 @@ public final class PostsViewModel: ObservableObject { self.router = router self.config = config self.storage = storage - - cancellable = postStateSubject - .receive(on: RunLoop.main) - .sink(receiveValue: { [weak self] state in - guard let self, let state else { return } + + observationTask = Task { @MainActor in + for await state in postStateSubject.values { + if Task.isCancelled { + break + } + guard let state = state else { continue } switch state { case let .followed(id, followed): self.updatePostFollowedState(id: id, followed: followed) @@ -112,7 +116,12 @@ public final class PostsViewModel: ObservableObject { case let .reported(id, reported): self.updatePostReportedState(id: id, reported: reported) } - }) + } + } + } + + deinit { + observationTask?.cancel() } public func resetPosts() { @@ -120,6 +129,10 @@ public final class PostsViewModel: ObservableObject { totalPages = 1 } + public func cleanup() { + observationTask?.cancel() + } + public func sort(by value: SortType) { self.sortTitle = value self.filteredPosts = self.discussionPosts diff --git a/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift b/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift index 6c5a824f4..799542908 100644 --- a/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift +++ b/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift @@ -2297,9 +2297,10 @@ public final class DiscussionRouterMock: DiscussionRouter, @unchecked Sendable { public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Sendable { public init() { } - public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false) { + public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false, internetState: InternetState? = nil) { self.isInternetAvaliable = isInternetAvaliable self.isMobileData = isMobileData + self.internetState = internetState } @@ -2315,6 +2316,9 @@ public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Se get { return _internetReachableSubject } set { _internetReachableSubject = newValue } } + + + public var internetState: InternetState? = nil } public final class DownloadManagerProtocolMock: DownloadManagerProtocol, @unchecked Sendable { diff --git a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift index 68a582509..40e96d19b 100644 --- a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift @@ -52,7 +52,7 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { interactor: interactor, storage: storage, router: router, - debounce: .test) + debounceInterval: 0.1) viewModel.searchText = "Test" @@ -79,7 +79,7 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { interactor: interactor, storage: storage, router: router, - debounce: .test) + debounceInterval: 0.1) viewModel.searchText = "Test" @@ -105,7 +105,7 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { interactor: interactor, storage: storage, router: router, - debounce: .test) + debounceInterval: 0.1) viewModel.searchText = "Test" @@ -129,7 +129,7 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { interactor: interactor, storage: storage, router: router, - debounce: .test) + debounceInterval: 0.1) viewModel.searchText = "" diff --git a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift index 04f36babe..a93b988de 100644 --- a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift @@ -88,7 +88,7 @@ final class DiscussionTopicsViewModelTests: XCTestCase { XCTAssertEqual(interactor.getTopicsCallCount, 1) XCTAssertNil(viewModel.topics) - XCTAssertNil(viewModel.discussionTopics) + XCTAssertTrue(viewModel.discussionTopics.isEmpty) XCTAssertFalse(viewModel.isShowProgress) XCTAssertFalse(viewModel.isShowRefresh) } @@ -114,7 +114,7 @@ final class DiscussionTopicsViewModelTests: XCTestCase { XCTAssertEqual(interactor.getTopicsCallCount, 1) XCTAssertNil(viewModel.topics) - XCTAssertNil(viewModel.discussionTopics) + XCTAssertTrue(viewModel.discussionTopics.isEmpty) XCTAssertFalse(viewModel.isShowProgress) XCTAssertFalse(viewModel.isShowRefresh) } diff --git a/Downloads/Downloads.xcodeproj/project.pbxproj b/Downloads/Downloads.xcodeproj/project.pbxproj index 7edc1d020..4216fb399 100644 --- a/Downloads/Downloads.xcodeproj/project.pbxproj +++ b/Downloads/Downloads.xcodeproj/project.pbxproj @@ -550,7 +550,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -590,7 +590,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -745,7 +745,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DownloadsTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -767,7 +767,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DownloadsTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -864,7 +864,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -895,7 +895,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DownloadsTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -992,7 +992,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1023,7 +1023,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DownloadsTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1120,7 +1120,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1151,7 +1151,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DownloadsTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1241,7 +1241,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1271,7 +1271,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DownloadsTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1361,7 +1361,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1391,7 +1391,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DownloadsTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1481,7 +1481,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1511,7 +1511,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DownloadsTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Downloads/Downloads/Presentation/AppDownloadsView.swift b/Downloads/Downloads/Presentation/AppDownloadsView.swift index 0598ec2bd..15e0c4ac9 100644 --- a/Downloads/Downloads/Presentation/AppDownloadsView.swift +++ b/Downloads/Downloads/Presentation/AppDownloadsView.swift @@ -15,8 +15,8 @@ public struct AppDownloadsView: View { @Environment(\.isHorizontal) private var isHorizontal private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - @StateObject private var viewModel: AppDownloadsViewModel - + @Bindable private var viewModel: AppDownloadsViewModel + private func columns() -> [GridItem] { isHorizontal || idiom == .pad ? [ @@ -29,7 +29,7 @@ public struct AppDownloadsView: View { } public init(viewModel: AppDownloadsViewModel) { - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel } public var body: some View { diff --git a/Downloads/Downloads/Presentation/AppDownloadsViewModel.swift b/Downloads/Downloads/Presentation/AppDownloadsViewModel.swift index 4e5489dd5..d4170a40a 100644 --- a/Downloads/Downloads/Presentation/AppDownloadsViewModel.swift +++ b/Downloads/Downloads/Presentation/AppDownloadsViewModel.swift @@ -12,17 +12,18 @@ import SwiftUI //swiftlint:disable type_body_length @MainActor -public final class AppDownloadsViewModel: ObservableObject { +@Observable +public final class AppDownloadsViewModel { private var cancellables = Set() private var downloadQueue = [String]() private var isProcessingQueue = false - @Published var courses: [DownloadCoursePreview] = [] - @Published var downloadedSizes: [String: Int64] = [:] - @Published var downloadStates: [String: DownloadState] = [:] - @Published var showError: Bool = false - @Published private(set) var fetchInProgress = false + var courses: [DownloadCoursePreview] = [] + var downloadedSizes: [String: Int64] = [:] + var downloadStates: [String: DownloadState] = [:] + var showError: Bool = false + private(set) var fetchInProgress = false private var courseTasks: [String: [DownloadDataTask]] = [:] private var courseSizes: [String: Int64] = [:] diff --git a/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift b/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift index 2d10940fd..69f85c6fa 100644 --- a/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift +++ b/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift @@ -1642,9 +1642,10 @@ public final class DownloadsHelperProtocolMock: DownloadsHelperProtocol, @unchec public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Sendable { public init() { } - public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false) { + public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false, internetState: InternetState? = nil) { self.isInternetAvaliable = isInternetAvaliable self.isMobileData = isMobileData + self.internetState = internetState } @@ -1660,6 +1661,9 @@ public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Se get { return _internetReachableSubject } set { _internetReachableSubject = newValue } } + + + public var internetState: InternetState? = nil } public final class DownloadManagerProtocolMock: DownloadManagerProtocol, @unchecked Sendable { diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index ee7d5fbaa..51bfc2b81 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -723,7 +723,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -815,7 +815,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -901,7 +901,7 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -913,7 +913,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -993,7 +993,7 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -1005,7 +1005,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1157,7 +1157,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1195,7 +1195,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 7d3a3e619..17e5744fe 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -152,8 +152,9 @@ class ScreenAssembly: Assembly { storage: r.resolve(CoreStorage.self)! ) } - - container.register(DiscoveryWebviewViewModel.self) { @MainActor r, sourceScreen in + .inObjectScope(.weak) + + container.register(DiscoveryWebviewViewModel.self) { @MainActor r, sourceScreen in DiscoveryWebviewViewModel( router: r.resolve(DiscoveryRouter.self)!, config: r.resolve(ConfigProtocol.self)!, @@ -164,6 +165,7 @@ class ScreenAssembly: Assembly { sourceScreen: sourceScreen ) } + .inObjectScope(.weak) container.register(ProgramWebviewViewModel.self) { @MainActor r in ProgramWebviewViewModel( @@ -175,6 +177,7 @@ class ScreenAssembly: Assembly { authInteractor: r.resolve(AuthInteractorProtocol.self)! ) } + .inObjectScope(.weak) container.register(SearchViewModel.self) { @MainActor r in SearchViewModel( @@ -213,6 +216,7 @@ class ScreenAssembly: Assembly { storage: r.resolve(CoreStorage.self)! ) } + .inObjectScope(.weak) container.register(PrimaryCourseDashboardViewModel.self) { @MainActor r in PrimaryCourseDashboardViewModel( @@ -224,7 +228,8 @@ class ScreenAssembly: Assembly { router: r.resolve(DashboardRouter.self)! ) } - + .inObjectScope(.container) + container.register(AllCoursesViewModel.self) { @MainActor r in AllCoursesViewModel( interactor: r.resolve(DashboardInteractorProtocol.self)!, @@ -233,6 +238,7 @@ class ScreenAssembly: Assembly { storage: r.resolve(CoreStorage.self)! ) } + .inObjectScope(.weak) // MARK: Profile @@ -264,6 +270,7 @@ class ScreenAssembly: Assembly { connectivity: r.resolve(ConnectivityProtocol.self)! ) } + .inObjectScope(.weak) container.register(EditProfileViewModel.self) { @MainActor r, userModel in EditProfileViewModel( userModel: userModel, @@ -353,6 +360,7 @@ class ScreenAssembly: Assembly { router: r.resolve(AppDatesRouter.self)! ) } + .inObjectScope(.weak) // MARK: Course container.register(CoursePersistenceProtocol.self) { r in @@ -395,6 +403,7 @@ class ScreenAssembly: Assembly { courseHelper: r.resolve(CourseDownloadHelperProtocol.self)! ) } + .inObjectScope(.weak) container.register( CourseDownloadHelperProtocol.self ) { @MainActor r in @@ -637,7 +646,7 @@ class ScreenAssembly: Assembly { interactor: r.resolve(DiscussionInteractorProtocol.self)!, storage: r.resolve(CoreStorage.self)!, router: r.resolve(DiscussionRouter.self)!, - debounce: .searchDebounce + debounceInterval: 0.8 ) } @@ -750,6 +759,7 @@ class ScreenAssembly: Assembly { analytics: r.resolve(DownloadsAnalytics.self)! ) } + .inObjectScope(.weak) } } // swiftlint:enable function_body_length closure_parameter_position type_body_length diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index d334f34e0..2da89bd63 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -323,7 +323,7 @@ public class Router: AuthorizationRouter, } public func showDiscussionsSearch(courseID: String, isBlackedOut: Bool) { - let viewModel = Container.shared.resolve(DiscussionSearchTopicsViewModel.self, argument: courseID)! + let viewModel = Container.shared.resolve(DiscussionSearchTopicsViewModel.self, argument: courseID)! let view = DiscussionSearchTopicsView(viewModel: viewModel) @@ -531,7 +531,8 @@ public class Router: AuthorizationRouter, let isDropdownActive = config?.uiComponents.courseDropDownNavigationEnabled ?? false let view = CourseUnitView(viewModel: viewModel, isDropdownActive: isDropdownActive) - return UIHostingController(rootView: view) + let controller = UIHostingController(rootView: view) + return controller } public func showCourseComponent( @@ -617,7 +618,6 @@ public class Router: AuthorizationRouter, showVideoNavigation: Bool, courseVideoStructure: CourseStructure? ) { - let controllerUnit = getUnitController( courseName: courseName, blockId: blockId, diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index a4816d2bf..22f6928f8 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -19,12 +19,9 @@ import Theme import OEXFoundation struct MainScreenView: View { - - @State private var disableAllTabs: Bool = false - @State private var updateAvailable: Bool = false - - @ObservedObject private(set) var viewModel: MainScreenViewModel - + + @Bindable private(set) var viewModel: MainScreenViewModel + init(viewModel: MainScreenViewModel) { self.viewModel = viewModel UITabBar.appearance().isTranslucent = false @@ -202,10 +199,10 @@ struct MainScreenView: View { } .onReceive(NotificationCenter.default.publisher(for: .onAppUpgradeAccountSettingsTapped)) { _ in viewModel.selection = .profile - disableAllTabs = true + viewModel.disableAllTabs = true } .onReceive(NotificationCenter.default.publisher(for: .onNewVersionAvaliable)) { _ in - updateAvailable = true + viewModel.updateAvailable = true } .onReceive(NotificationCenter.default.publisher(for: .showDownloadFailed)) { downloads in if let downloads = downloads.object as? [DownloadDataTask] { @@ -215,7 +212,7 @@ struct MainScreenView: View { } } .onChange(of: viewModel.selection) { _ in - if disableAllTabs { + if viewModel.disableAllTabs { viewModel.selection = .profile } } @@ -247,8 +244,7 @@ struct MainScreenView: View { } } .accentColor(Theme.Colors.accentXColor) - - if updateAvailable { + if viewModel.updateAvailable { UpdateNotificationView(config: viewModel.config) } } diff --git a/OpenEdX/View/MainScreenViewModel.swift b/OpenEdX/View/MainScreenViewModel.swift index 67f24f528..a5a3f49fc 100644 --- a/OpenEdX/View/MainScreenViewModel.swift +++ b/OpenEdX/View/MainScreenViewModel.swift @@ -25,7 +25,8 @@ public enum MainTab { } @MainActor -final class MainScreenViewModel: ObservableObject { +@Observable +final class MainScreenViewModel { private let analytics: MainScreenAnalytics let config: ConfigProtocol @@ -39,8 +40,10 @@ final class MainScreenViewModel: ObservableObject { private var cancellables = Set() private var postLoginData: PostLoginData? - @Published var selection: MainTab = .dashboard - @Published var showRegisterBanner: Bool = false + var selection: MainTab = .dashboard + var showRegisterBanner: Bool = false + var disableAllTabs = false + var updateAvailable = false init(analytics: MainScreenAnalytics, config: ConfigProtocol, diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index a44199e1f..0763f7467 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -893,7 +893,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -928,7 +928,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1025,7 +1025,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1123,7 +1123,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1215,7 +1215,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1306,7 +1306,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1333,7 +1333,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1354,7 +1354,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1375,7 +1375,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1396,7 +1396,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1417,7 +1417,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1438,7 +1438,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1529,7 +1529,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1557,7 +1557,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1642,7 +1642,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1669,7 +1669,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift index fd29e33af..8c6fdeb93 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift @@ -15,6 +15,7 @@ import Core import OEXFoundation // MARK: - CalendarManager +@MainActor public final class CalendarManager: CalendarManagerProtocol { let eventStore = EKEventStore() diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift index 362a1f27e..cf546fb1c 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift @@ -10,8 +10,8 @@ import Theme import Core public struct CoursesToSyncView: View { - - @ObservedObject + + @Bindable private var viewModel: DatesAndCalendarViewModel @Environment(\.isHorizontal) private var isHorizontal @@ -80,6 +80,9 @@ public struct CoursesToSyncView: View { } .ignoresSafeArea(.all, edges: .horizontal) } + .onChange(of: viewModel.hideInactiveCourses) { _, hide in + viewModel.profileStorage.hideInactiveCourses = hide + } } private var coursesList: some View { diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift index 671c1f2b9..df173f16d 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift @@ -10,8 +10,8 @@ import Theme import Core public struct DatesAndCalendarView: View { - - @ObservedObject + + @Bindable private var viewModel: DatesAndCalendarViewModel @State private var screenDimmed: Bool = false diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift index 9549f53fa..84f870571 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift @@ -6,7 +6,6 @@ // import SwiftUI -import Combine import EventKit import Theme import BranchSDK @@ -17,35 +16,36 @@ import OEXFoundation // MARK: - DatesAndCalendarViewModel @MainActor -public final class DatesAndCalendarViewModel: ObservableObject { - @Published var showCalendaAccessDenied: Bool = false - @Published var showDisableCalendarSync: Bool = false - @Published var showError: Bool = false - @Published var openNewCalendarView: Bool = false +@Observable +public final class DatesAndCalendarViewModel { + var showCalendaAccessDenied: Bool = false + var showDisableCalendarSync: Bool = false + var showError: Bool = false + var openNewCalendarView: Bool = false - @Published var accountSelection: DropDownPicker.DownPickerOption? = .init( + var accountSelection: DropDownPicker.DownPickerOption? = .init( title: ProfileLocalization.Calendar.Dropdown.icloud ) - @Published var calendarName: String = "" - @Published var oldCalendarName: String = "" - @Published var colorSelection: DropDownPicker.DownPickerOption? = .init(color: .accent) - @Published var oldColorSelection: DropDownPicker.DownPickerOption? = .init(color: .accent) + var calendarName: String = "" + var oldCalendarName: String = "" + var colorSelection: DropDownPicker.DownPickerOption? = .init(color: .accent) + var oldColorSelection: DropDownPicker.DownPickerOption? = .init(color: .accent) - @Published var assignmentStatus: AssignmentStatus = .synced - @Published var courseCalendarSync: Bool = true - @Published var reconnectRequired: Bool = false - @Published var openChangeSyncView: Bool = false - @Published var syncingCoursesCount: Int = 0 + var assignmentStatus: AssignmentStatus = .synced + var courseCalendarSync: Bool = true + var reconnectRequired: Bool = false + var openChangeSyncView: Bool = false + var syncingCoursesCount: Int = 0 - @Published var coursesForSync = [CourseForSync]() + var coursesForSync = [CourseForSync]() private var coursesForSyncBeforeChanges = [CourseForSync]() private(set) var coursesForDeleting = [CourseForSync]() private(set) var coursesForAdding = [CourseForSync]() - @Published var synced: Bool = true - @Published var hideInactiveCourses: Bool = false + var synced: Bool = true + var hideInactiveCourses: Bool = false var errorMessage: String? { didSet { @@ -74,12 +74,11 @@ public final class DatesAndCalendarViewModel: ObservableObject { var router: ProfileRouter private var interactor: ProfileInteractorProtocol - @Published var profileStorage: ProfileStorage + var profileStorage: ProfileStorage private var persistence: ProfilePersistenceProtocol private var calendarManager: CalendarManagerProtocol private var connectivity: ConnectivityProtocol - - private var cancellables = Set() + var calendarNameHint: String public init( @@ -120,27 +119,7 @@ public final class DatesAndCalendarViewModel: ObservableObject { } self.courseCalendarSync = calendarSettings.courseCalendarSync self.hideInactiveCourses = profileStorage.hideInactiveCourses ?? false - - $hideInactiveCourses - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] hide in - guard let self = self else { return } - self.profileStorage.hideInactiveCourses = hide - }) - .store(in: &cancellables) - - $courseCalendarSync - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] sync in - guard let self = self else { return } - if !sync { - Task { - await self.showDisableCalendarSync() - } - } - }) - .store(in: &cancellables) - + updateCoursesCount() } diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift index cff53dde2..8ed0dfb23 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift @@ -26,7 +26,7 @@ struct NewCalendarView: View { } } - @ObservedObject + @Bindable private var viewModel: DatesAndCalendarViewModel @Environment(\.isHorizontal) private var isHorizontal private var beginSyncingTapped: (() -> Void) = {} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift index 2be3eeb38..619a30eb7 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift @@ -10,8 +10,8 @@ import Theme import Core public struct SyncCalendarOptionsView: View { - - @ObservedObject + + @Bindable private var viewModel: DatesAndCalendarViewModel @State private var screenDimmed: Bool = false @@ -198,9 +198,12 @@ public struct SyncCalendarOptionsView: View { await viewModel.fetchCourses() } } - .onChange(of: viewModel.courseCalendarSync) { sync in + .onChange(of: viewModel.courseCalendarSync) { _, sync in if !sync { screenDimmed = true + withAnimation(.bouncy(duration: 0.3)) { + viewModel.showDisableCalendarSync = true + } } } .onAppear { diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 2e2d30c3d..a377632ab 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -11,8 +11,8 @@ import OEXFoundation import Theme public struct DeleteAccountView: View { - - @ObservedObject + + @Bindable private var viewModel: DeleteAccountViewModel public init(viewModel: DeleteAccountViewModel) { diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift index 9ffb9cb1b..6d06b74c2 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift @@ -10,10 +10,11 @@ import Core import SwiftUI @MainActor -public final class DeleteAccountViewModel: ObservableObject { +@Observable +public final class DeleteAccountViewModel { - @Published private(set) var isShowProgress = false - @Published var showError: Bool = false + private(set) var isShowProgress = false + var showError: Bool = false var errorMessage: String? { didSet { withAnimation { @@ -22,8 +23,8 @@ public final class DeleteAccountViewModel: ObservableObject { } } - @Published var password = "" - @Published var incorrectPassword: Bool = false + var password = "" + var incorrectPassword: Bool = false private let interactor: ProfileInteractorProtocol public let router: ProfileRouter diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index cfe8f2d66..345df8462 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -12,7 +12,7 @@ import Theme public struct EditProfileView: View { - @ObservedObject public var viewModel: EditProfileViewModel + @Bindable public var viewModel: EditProfileViewModel @State private var showingImagePicker = false @State private var showingBottomSheet = false @@ -119,14 +119,19 @@ public struct EditProfileView: View { } } } - .onReceive(viewModel.yearsConfiguration.$text - .combineLatest(viewModel.countriesConfiguration.$text, - viewModel.spokenLanguageConfiguration.$text), - perform: { _ in + .onChange(of: viewModel.yearsConfiguration.text) { _, _ in viewModel.checkChanges() viewModel.checkProfileType() - }) - .onChange(of: viewModel.profileChanges) { _ in + } + .onChange(of: viewModel.countriesConfiguration.text) { _, _ in + viewModel.checkChanges() + viewModel.checkProfileType() + } + .onChange(of: viewModel.spokenLanguageConfiguration.text) { _, _ in + viewModel.checkChanges() + viewModel.checkProfileType() + } + .onChange(of: viewModel.profileChanges) { _, _ in viewModel.checkChanges() viewModel.checkProfileType() } @@ -221,7 +226,22 @@ public struct EditProfileView: View { BackNavigationButton(color: Theme.Colors.accentColor) { viewModel.backButtonTapped() } - .offset(x: -8, y: -1.5) + .offset( + x: { + if #available(iOS 26.0, *) { + return 6 + } else { + return -8 + } + }(), + y: { + if #available(iOS 26.0, *) { + return 1 + } else { + return -1.5 + } + }() + ) } ) ToolbarItem(placement: .navigationBarTrailing, content: { diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift index d8a1b3974..b2e5acaef 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift @@ -18,16 +18,17 @@ public struct Changes: Equatable, Sendable { } @MainActor -public class EditProfileViewModel: ObservableObject { +@Observable +public class EditProfileViewModel { + + private(set) var userModel: UserProfile + private(set) var selectedCountry: PickerItem? + private(set) var selectedSpokeLanguage: PickerItem? + private(set) var selectedYearOfBirth: PickerItem? - @Published private(set) var userModel: UserProfile - @Published private(set) var selectedCountry: PickerItem? - @Published private(set) var selectedSpokeLanguage: PickerItem? - @Published private(set) var selectedYearOfBirth: PickerItem? - var profileDidEdit: (((UserProfile?, UIImage?)) -> Void)? var oldAvatar: UIImage? - + private let minimumFullAccountAge = 13 private let currentYear = Calendar.current.component(.year, from: Date()) public let profileTypes: [ProfileType] = [.full, .limited] @@ -46,7 +47,6 @@ public class EditProfileViewModel: ObservableObject { ProfileLocalization.Edit.Fields.spokenLangugae ) - @Published public var profileChanges: Changes = .init( shortBiography: "", profileType: .limited, @@ -55,14 +55,14 @@ public class EditProfileViewModel: ObservableObject { isAvatarSaved: false ) - @Published public var inputImage: UIImage? + public var inputImage: UIImage? private(set) var isYongUser: Bool = false private(set) var isEditable: Bool = true - @Published var isChanged = false - @Published private(set) var isShowProgress = false - @Published var showError: Bool = false - @Published var showAlert: Bool = false + var isChanged = false + private(set) var isShowProgress = false + var showError: Bool = false + var showAlert: Bool = false var errorMessage: String? { didSet { @@ -173,7 +173,14 @@ public class EditProfileViewModel: ObservableObject { } else { yearOfBirth = userModel.yearOfBirth } - if yearOfBirth == 0 || currentYear - yearOfBirth < minimumFullAccountAge { + + if yearOfBirth == 0 { + if profileChanges.profileType == .limited { + alertMessage = ProfileLocalization.Edit.tooYongUser + } else { + profileChanges.profileType.toggle() + } + } else if currentYear - yearOfBirth < minimumFullAccountAge { alertMessage = ProfileLocalization.Edit.tooYongUser } else { profileChanges.profileType.toggle() @@ -183,30 +190,27 @@ public class EditProfileViewModel: ObservableObject { } func checkProfileType() { + let yearOfBirth: Int if yearsConfiguration.text != "" { - let yearOfBirth = yearsConfiguration.text - if currentYear - (Int(yearOfBirth) ?? 0) < minimumFullAccountAge { - profileChanges.profileType = .limited - isYongUser = true - } else { - withAnimation { - isYongUser = false - } - } + yearOfBirth = Int(yearsConfiguration.text) ?? 0 } else { - if (currentYear - userModel.yearOfBirth) < minimumFullAccountAge { - profileChanges.profileType = .limited - isYongUser = true - } else { - withAnimation { - isYongUser = false - } - } + yearOfBirth = userModel.yearOfBirth } - if profileChanges.profileType == .full { - isEditable = true - } else { + + if yearOfBirth == 0 { + withAnimation { + isYongUser = false + } isEditable = false + } else if currentYear - yearOfBirth < minimumFullAccountAge { + profileChanges.profileType = .limited + isYongUser = true + isEditable = false + } else { + withAnimation { + isYongUser = false + } + isEditable = profileChanges.profileType == .full } } @@ -252,7 +256,7 @@ public class EditProfileViewModel: ObservableObject { profileChanges.isAvatarSaved = true } checkChanges() - + if isChanged { if !parameters.isEmpty { isShowProgress = true @@ -325,6 +329,7 @@ public class EditProfileViewModel: ObservableObject { } generateFieldConfigurations() + checkProfileType() } private func generateYears() { diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 6ff685b80..67a855c71 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -13,10 +13,10 @@ import OEXFoundation public struct ProfileView: View { - @StateObject private var viewModel: ProfileViewModel + @Bindable private var viewModel: ProfileViewModel public init(viewModel: ProfileViewModel) { - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.viewModel = viewModel } public var body: some View { diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index 123c31d26..395e3b613 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -10,12 +10,13 @@ import Core import SwiftUI @MainActor -public final class ProfileViewModel: ObservableObject { +@Observable +public final class ProfileViewModel { - @Published public var userModel: UserProfile? - @Published public var updatedAvatar: UIImage? - @Published private(set) var isShowProgress = false - @Published var showError: Bool = false + public var userModel: UserProfile? + public var updatedAvatar: UIImage? + private(set) var isShowProgress = false + var showError: Bool = false var errorMessage: String? { didSet { withAnimation { diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index 986426d87..799df753d 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -20,7 +20,7 @@ struct ProfileSupportInfoView: View { let title: String } - @ObservedObject var viewModel: SettingsViewModel + var viewModel: SettingsViewModel var body: some View { Text(ProfileLocalization.supportInfo) diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift index da7e80fd6..1446a6e20 100644 --- a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift @@ -14,7 +14,7 @@ import Theme public struct UserProfileView: View { @Environment(\.dismiss) private var dismiss - @ObservedObject private var viewModel: UserProfileViewModel + private var viewModel: UserProfileViewModel public var isSheet: Bool diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift index 6a723c800..61b08b56c 100644 --- a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift @@ -8,11 +8,13 @@ import Core import SwiftUI -public class UserProfileViewModel: ObservableObject { +@Observable +@MainActor +public class UserProfileViewModel { - @Published public var userModel: UserProfile? - @Published private(set) var isShowProgress = false - @Published var showError: Bool = false + public var userModel: UserProfile? + private(set) var isShowProgress = false + var showError: Bool = false var errorMessage: String? { didSet { withAnimation { diff --git a/Profile/Profile/Presentation/Settings/ManageAccountView.swift b/Profile/Profile/Presentation/Settings/ManageAccountView.swift index a7b79625c..c2f4f2c5f 100644 --- a/Profile/Profile/Presentation/Settings/ManageAccountView.swift +++ b/Profile/Profile/Presentation/Settings/ManageAccountView.swift @@ -11,8 +11,8 @@ import OEXFoundation import Theme public struct ManageAccountView: View { - - @ObservedObject + + @Bindable private var viewModel: ManageAccountViewModel @Environment(\.isHorizontal) private var isHorizontal diff --git a/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift b/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift index 16415b17f..571c9e924 100644 --- a/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift +++ b/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift @@ -10,12 +10,13 @@ import Core import SwiftUI @MainActor -public final class ManageAccountViewModel: ObservableObject { +@Observable +public final class ManageAccountViewModel { - @Published public var userModel: UserProfile? - @Published public var updatedAvatar: UIImage? - @Published private(set) var isShowProgress = false - @Published var showError: Bool = false + public var userModel: UserProfile? + public var updatedAvatar: UIImage? + private(set) var isShowProgress = false + var showError: Bool = false var errorMessage: String? { didSet { withAnimation { diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 2304686e0..957b81b5c 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -13,7 +13,6 @@ import Theme public struct SettingsView: View { - @ObservedObject private var viewModel: SettingsViewModel @Environment(\.isHorizontal) private var isHorizontal diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 3b4a2c7f9..2aae0526b 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -11,11 +11,12 @@ import SwiftUI import Combine @MainActor -public final class SettingsViewModel: ObservableObject { +@Observable +public final class SettingsViewModel { - @Published private(set) var isShowProgress = false - @Published var showError: Bool = false - @Published var wifiOnly: Bool { + private(set) var isShowProgress = false + var showError: Bool = false + var wifiOnly: Bool { willSet { if newValue != wifiOnly { userSettings.wifiOnly = newValue @@ -26,7 +27,7 @@ public final class SettingsViewModel: ObservableObject { } } - @Published var selectedQuality: StreamingQuality { + var selectedQuality: StreamingQuality { willSet { if newValue != selectedQuality { userSettings.streamingQuality = newValue @@ -53,9 +54,9 @@ public final class SettingsViewModel: ObservableObject { case updateRequired } - @Published var versionState: VersionState = .actual - @Published var currentVersion: String = "" - @Published var latestVersion: String = "" + var versionState: VersionState = .actual + var currentVersion: String = "" + var latestVersion: String = "" var errorMessage: String? { didSet { @@ -65,7 +66,7 @@ public final class SettingsViewModel: ObservableObject { } } - @Published private(set) var userSettings: UserSettings + private(set) var userSettings: UserSettings private let interactor: ProfileInteractorProtocol private let downloadManager: DownloadManagerProtocol diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index abcf5da33..551077d0e 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -13,7 +13,6 @@ import Theme public struct VideoQualityView: View { - @ObservedObject private var viewModel: SettingsViewModel @Environment(\.isHorizontal) private var isHorizontal diff --git a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift index bc05ba3fb..3b12032f7 100644 --- a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift +++ b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift @@ -11,8 +11,7 @@ import Theme public struct VideoSettingsView: View { - @ObservedObject - private var viewModel: SettingsViewModel + @Bindable private var viewModel: SettingsViewModel @Environment(\.isHorizontal) private var isHorizontal public init(viewModel: SettingsViewModel) { diff --git a/Profile/ProfileTests/Generated/ProfileMocks.generated.swift b/Profile/ProfileTests/Generated/ProfileMocks.generated.swift index 52fa772a7..3a6a058c9 100644 --- a/Profile/ProfileTests/Generated/ProfileMocks.generated.swift +++ b/Profile/ProfileTests/Generated/ProfileMocks.generated.swift @@ -2593,9 +2593,10 @@ public final class ProfileAnalyticsMock: ProfileAnalytics { public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Sendable { public init() { } - public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false) { + public init(isInternetAvaliable: Bool = false, isMobileData: Bool = false, internetState: InternetState? = nil) { self.isInternetAvaliable = isInternetAvaliable self.isMobileData = isMobileData + self.internetState = internetState } @@ -2611,6 +2612,9 @@ public final class ConnectivityProtocolMock: ConnectivityProtocol, @unchecked Se get { return _internetReachableSubject } set { _internetReachableSubject = newValue } } + + + public var internetState: InternetState? = nil } public final class DownloadManagerProtocolMock: DownloadManagerProtocol, @unchecked Sendable { diff --git a/Theme/Theme.xcodeproj/project.pbxproj b/Theme/Theme.xcodeproj/project.pbxproj index c064c986a..f073cc71f 100644 --- a/Theme/Theme.xcodeproj/project.pbxproj +++ b/Theme/Theme.xcodeproj/project.pbxproj @@ -491,7 +491,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -584,7 +584,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -682,7 +682,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -775,7 +775,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -873,7 +873,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -966,7 +966,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1122,7 +1122,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1157,7 +1157,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj index 893242164..ce86d51b4 100644 --- a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj +++ b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj @@ -633,7 +633,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -672,7 +672,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -701,7 +701,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -722,7 +722,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -818,7 +818,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -848,7 +848,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -944,7 +944,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -974,7 +974,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1070,7 +1070,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1100,7 +1100,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1189,7 +1189,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1218,7 +1218,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1307,7 +1307,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1336,7 +1336,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1425,7 +1425,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1454,7 +1454,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift index 00cb6a252..4562c0bc7 100644 --- a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift @@ -12,15 +12,12 @@ import Theme public struct WhatsNewView: View { private let router: WhatsNewRouter - - @ObservedObject - private var viewModel: WhatsNewViewModel - + + @Bindable private var viewModel: WhatsNewViewModel + @Environment(\.isHorizontal) private var isHorizontal - - @State var index = 0 - + public init(router: WhatsNewRouter, viewModel: WhatsNewViewModel) { self.router = router self.viewModel = viewModel @@ -32,7 +29,7 @@ public struct WhatsNewView: View { Theme.Colors.background .ignoresSafeArea() adaptiveStack(isHorizontal: isHorizontal) { - TabView(selection: $index) { + TabView(selection: $viewModel.viewIndex) { ForEach(Array(viewModel.newItems.enumerated()), id: \.offset) { _, new in adaptiveStack(isHorizontal: isHorizontal) { ZStack(alignment: .center) { @@ -94,9 +91,9 @@ public struct WhatsNewView: View { HStack(spacing: 36) { WhatsNewNavigationButton(type: .previous, action: { - if index != 0 { + if viewModel.viewIndex != 0 { withAnimation(.linear(duration: 0.3)) { - index -= 1 + viewModel.viewIndex -= 1 } } }) @@ -105,9 +102,9 @@ public struct WhatsNewView: View { WhatsNewNavigationButton( type: viewModel.index < viewModel.newItems.count - 1 ? .next : .done, action: { - if index < viewModel.newItems.count - 1 { + if viewModel.viewIndex < viewModel.newItems.count - 1 { withAnimation(.linear(duration: 0.3)) { - index += 1 + viewModel.viewIndex += 1 } } else { router.showMainOrWhatsNewScreen( @@ -140,7 +137,7 @@ public struct WhatsNewView: View { .accessibilityIdentifier("whatsnew_pagecontrol") } - }.onChange(of: index) { ind in + }.onChange(of: viewModel.viewIndex) { ind in withAnimation(.linear(duration: 0.3)) { viewModel.index = ind } diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift index 8c9086b3a..157c092d5 100644 --- a/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift @@ -10,9 +10,12 @@ import Core import Swinject @MainActor -public class WhatsNewViewModel: ObservableObject { - @Published var index: Int = 0 - @Published var newItems: [WhatsNewPage] = [] +@Observable public class WhatsNewViewModel { + + var index: Int = 0 + var viewIndex: Int = 0 + var newItems: [WhatsNewPage] = [] + private let storage: WhatsNewStorage var sourceScreen: LogistrationSourceScreen let analytics: WhatsNewAnalytics diff --git a/fastlane/Fastfile b/fastlane/Fastfile index acb49e42f..8f5f2e7e0 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -15,7 +15,7 @@ update_fastlane before_all do xcodes( - version: '16.4', + version: '26.2', select_for_current_build_only: true, )