diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/study_plan/fragment/StudyPlanFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/study_plan/fragment/StudyPlanFragment.kt index 78014b3eae..545a533a27 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/study_plan/fragment/StudyPlanFragment.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/study_plan/fragment/StudyPlanFragment.kt @@ -202,6 +202,9 @@ class StudyPlanFragment : ) ) } + is StudyPlanScreenFeature.Action.ViewAction.NotificationDailyStudyReminderWidgetViewAction -> { + // TODO: ALTAPPS-1347 handle notification daily study reminder widget view actions + } } } diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index e86114ef5e..a0229bac58 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -178,6 +178,8 @@ MaxLineLength:ExpandableTextView.kt$ExpandableTextView$* MaxLineLength:HtmlText.kt$text MaxLineLength:LinearProgressIndicator.kt$* + MaxLineLength:NotificationDailyStudyReminderWidgetComponentImpl.kt$NotificationDailyStudyReminderWidgetComponentImpl$override + MaxLineLength:NotificationDailyStudyReminderWidgetComponentImpl.kt$NotificationDailyStudyReminderWidgetComponentImpl$private MaxLineLength:ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticEvent.kt$ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticEvent$* MaxLineLength:RepositoryCacheProxy.kt$RepositoryCacheProxy$* MaxLineLength:StepComponentImpl.kt$StepComponentImpl$nextLearningActivityStateRepository = appGraph.stateRepositoriesComponent.nextLearningActivityStateRepository diff --git a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj index aee7473de5..5a407b33f9 100644 --- a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj +++ b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj @@ -307,6 +307,7 @@ 2C80D4FD288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D4FC288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift */; }; 2C80D4FF288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D4FE288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift */; }; 2C80D503288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D502288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift */; }; + 2C8118292CA3BEB600DCE9A8 /* NotificationDailyStudyReminderWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8118282CA3BEB600DCE9A8 /* NotificationDailyStudyReminderWidgetView.swift */; }; 2C829B912B88583300765335 /* StepQuizUnsupportedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C829B902B88583300765335 /* StepQuizUnsupportedView.swift */; }; 2C82BA322844B01D004C9013 /* PlaceholderView+Configurations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C82BA312844B01D004C9013 /* PlaceholderView+Configurations.swift */; }; 2C83FBBE2B177633007AD7E2 /* LeaderboardTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C83FBBD2B177633007AD7E2 /* LeaderboardTab.swift */; }; @@ -1118,6 +1119,7 @@ 2C80D4FC288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenViewModel.swift; sourceTree = ""; }; 2C80D4FE288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenOutputProtocol.swift; sourceTree = ""; }; 2C80D502288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeNavigationState.swift; sourceTree = ""; }; + 2C8118282CA3BEB600DCE9A8 /* NotificationDailyStudyReminderWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDailyStudyReminderWidgetView.swift; sourceTree = ""; }; 2C829B902B88583300765335 /* StepQuizUnsupportedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizUnsupportedView.swift; sourceTree = ""; }; 2C82BA312844B01D004C9013 /* PlaceholderView+Configurations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlaceholderView+Configurations.swift"; sourceTree = ""; }; 2C83FBBD2B177633007AD7E2 /* LeaderboardTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaderboardTab.swift; sourceTree = ""; }; @@ -1937,6 +1939,7 @@ 2CB0ADE92B04AC9E0089D557 /* HomeSubmodules */, E6992F3BBF430924F32DC178 /* Leaderboard */, 1B096FE500BA52CA6E56B26D /* ManageSubscription */, + 2C8118272CA3BE9900DCE9A8 /* NotificationDailyStudyReminderWidget */, B58361EACE24BF4B761F10BA /* NotificationsOnboarding */, 2C9320F32B68F13000999992 /* Paywall */, 2CE3F5BA2BD7AE7C000B51A4 /* ProblemsLimitInfo */, @@ -2950,6 +2953,14 @@ path = Cells; sourceTree = ""; }; + 2C8118272CA3BE9900DCE9A8 /* NotificationDailyStudyReminderWidget */ = { + isa = PBXGroup; + children = ( + 2C8118282CA3BEB600DCE9A8 /* NotificationDailyStudyReminderWidgetView.swift */, + ); + path = NotificationDailyStudyReminderWidget; + sourceTree = ""; + }; 2C82BA302844AFED004C9013 /* PlaceholderView */ = { isa = PBXGroup; children = ( @@ -5378,6 +5389,7 @@ 2C9AA3F52C24611300F5170E /* WelcomeOnboardingChooseProgrammingLanguageView.swift in Sources */, 2C20FBA8284F193A006D879E /* ContentProcessingRule.swift in Sources */, E9AB311429DED7FE00645376 /* StudyPlanSectionItemIconView.swift in Sources */, + 2C8118292CA3BEB600DCE9A8 /* NotificationDailyStudyReminderWidgetView.swift in Sources */, 2C7CB66B2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift in Sources */, 2CBC97CA2A5553330078E445 /* StageImplementStageCompletedModalViewController.swift in Sources */, 2C20FBC9284F6F97006D879E /* UnitConverters.swift in Sources */, diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift index 5b799f3d31..b8c400e5af 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift @@ -1,3 +1,4 @@ +// swiftlint:disable all import Foundation import shared @@ -279,6 +280,11 @@ enum Strings { static let subtitle = sharedStrings.users_interview_widget_subtitle.localized() } + enum NotificationDailyStudyReminderWidget { + static let title = sharedStrings.notification_daily_study_reminder_widget_title.localized() + static let subtitle = sharedStrings.notification_daily_study_reminder_widget_subtitle.localized() + } + // MARK: - Topics widget - enum TopicsWidget { @@ -673,3 +679,4 @@ enum Strings { static let showMoreButton = sharedStrings.comments_show_more_btn.localized() } } +// swiftlint:enable all \ No newline at end of file diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/NotificationDailyStudyReminderWidget/NotificationDailyStudyReminderWidgetView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/NotificationDailyStudyReminderWidget/NotificationDailyStudyReminderWidgetView.swift new file mode 100644 index 0000000000..c110a03406 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/NotificationDailyStudyReminderWidget/NotificationDailyStudyReminderWidgetView.swift @@ -0,0 +1,88 @@ +import SwiftUI + +extension NotificationDailyStudyReminderWidgetView { + struct Appearance { + let illustrationSize = CGSize(width: 89, height: 86) + + let spacing = LayoutInsets.defaultInset + let interitemSpacing = LayoutInsets.smallInset + } +} + +struct NotificationDailyStudyReminderWidgetView: View { + private(set) var appearance = Appearance() + + var onCallToAction: () -> Void + var onClose: () -> Void + var onViewedEvent: () -> Void + + var body: some View { + ZStack { + UIViewControllerEventsWrapper(onViewDidAppear: onViewedEvent) + + Button( + action: onCallToAction, + label: { + ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)) { + HStack(alignment: .center, spacing: appearance.spacing) { + textConent + illustration + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding([.leading, .vertical]) + + closeButton + } + .foregroundColor(.white) + .background(backgroundGradient) + } + ) + .buttonStyle(BounceButtonStyle()) + } + } + + private var textConent: some View { + VStack(alignment: .leading, spacing: appearance.interitemSpacing) { + Text(Strings.NotificationDailyStudyReminderWidget.title) + .font(.headline) + Text(Strings.NotificationDailyStudyReminderWidget.subtitle) + .font(.subheadline) + } + } + + private var illustration: some View { + Image(.usersInterviewWidgetIllustration) + .renderingMode(.original) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(size: appearance.illustrationSize) + } + + private var closeButton: some View { + Button( + action: onClose, + label: { + Image(systemName: "xmark.circle.fill") + .padding(.all, appearance.interitemSpacing) + } + ) + } + + private var backgroundGradient: some View { + Image(.usersInterviewWidgetGradient) + .renderingMode(.original) + .resizable() + .addBorder(color: .clear, width: 0) + } +} + +#if DEBUG +#Preview { + NotificationDailyStudyReminderWidgetView( + onCallToAction: {}, + onClose: {}, + onViewedEvent: {} + ) + .padding() +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/Toolbar/ToolbarProgress/SpacebotWowAnimationView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/Toolbar/ToolbarProgress/SpacebotWowAnimationView.swift index 661a5a4f3a..b512487e1a 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/Toolbar/ToolbarProgress/SpacebotWowAnimationView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/Toolbar/ToolbarProgress/SpacebotWowAnimationView.swift @@ -42,12 +42,12 @@ final class SpacebotWowAnimationView: UIView { animationView.play { [weak self] completed in completion?(completed) - guard let self else { + guard let strongSelf = self else { return } - self.animationView.alpha = self.appearance.animationViewAlphaHidden - self.animationView.currentProgress = 0 + strongSelf.animationView.alpha = strongSelf.appearance.animationViewAlphaHidden + strongSelf.animationView.currentProgress = 0 } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizMatching/StepQuizMatchingViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizMatching/StepQuizMatchingViewModel.swift index b721d2b292..9276c258cc 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizMatching/StepQuizMatchingViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizMatching/StepQuizMatchingViewModel.swift @@ -52,24 +52,24 @@ final class StepQuizMatchingViewModel: ObservableObject, StepQuizChildQuizInputP selectedColumnsIDs: matchItem.option != nil ? [matchItem.option.require().id] : [], isMultipleChoice: false, onColumnsSelected: { [weak self] selectedColumnsIDs in - guard let self, + guard let strongSelf = self, let selectedColumnID = selectedColumnsIDs.first, matchItem.option?.id != selectedColumnID, - let currentItemIndex = self.viewData.items.firstIndex(of: matchItem) else { + let currentItemIndex = strongSelf.viewData.items.firstIndex(of: matchItem) else { return } - if let swappingIndex = self.viewData.items.firstIndex(where: { $0.option?.id == selectedColumnID }) { - let tmp = self.viewData.items[currentItemIndex].option - self.viewData.items[currentItemIndex].option = self.viewData.items[swappingIndex].option - self.viewData.items[swappingIndex].option = tmp + if let swapIndex = strongSelf.viewData.items.firstIndex(where: { $0.option?.id == selectedColumnID }) { + let tmp = strongSelf.viewData.items[currentItemIndex].option + strongSelf.viewData.items[currentItemIndex].option = strongSelf.viewData.items[swapIndex].option + strongSelf.viewData.items[swapIndex].option = tmp } else { - self.viewData.items[currentItemIndex].option = self.options.first( + strongSelf.viewData.items[currentItemIndex].option = strongSelf.options.first( where: { $0.id == selectedColumnID } ) } - self.outputCurrentReply() + strongSelf.outputCurrentReply() } ) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanAssembly.swift index 7131fe2e94..619bab60ed 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanAssembly.swift @@ -5,6 +5,7 @@ final class StudyPlanAssembly: UIKitAssembly { let studyPlanScreenComponent = AppGraphBridge.sharedAppGraph.buildStudyPlanScreenComponent() let viewModel = StudyPlanViewModel( + notificationsRegistrationService: .shared, feature: studyPlanScreenComponent.studyPlanScreenFeature ) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift index 3e2b868c35..461f3d90c5 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift @@ -11,9 +11,22 @@ final class StudyPlanViewModel: FeatureViewModel< var studyPlanWidgetStateKs: StudyPlanWidgetViewStateKs { .init(state.studyPlanWidgetViewState) } var gamificationToolbarViewStateKs: GamificationToolbarFeatureViewStateKs { .init(state.toolbarViewState) } - var usersInterviewWidgetFeatureStateKs: UsersInterviewWidgetFeatureStateKs { + var usersInterviewWidgetStateKs: UsersInterviewWidgetFeatureStateKs { .init(state.usersInterviewWidgetState) } + var notificationDailyStudyReminderWidgetViewStateKs: NotificationDailyStudyReminderWidgetFeatureViewStateKs { + .init(state.notificationDailyStudyReminderWidgetViewState) + } + + private let notificationsRegistrationService: NotificationsRegistrationService + + init( + notificationsRegistrationService: NotificationsRegistrationService, + feature: Presentation_reduxFeature + ) { + self.notificationsRegistrationService = notificationsRegistrationService + super.init(feature: feature) + } override func shouldNotifyStateDidChange( oldState: StudyPlanScreenFeature.ViewState, @@ -24,10 +37,12 @@ final class StudyPlanViewModel: FeatureViewModel< func doLoadStudyPlan() { onNewMessage(StudyPlanScreenFeatureMessageInitialize()) + initializeNotificationDailyStudyReminderWidgetFeature() } func doRetryContentLoading() { onNewMessage(StudyPlanScreenFeatureMessageRetryContentLoading()) + initializeNotificationDailyStudyReminderWidgetFeature() } func doScreenBecomesActive() { @@ -136,6 +151,66 @@ final class StudyPlanViewModel: FeatureViewModel< } } +// MARK: - StudyPlanViewModel (NotificationDailyStudyReminderWidget) - + +extension StudyPlanViewModel { + func doNotificationDailyStudyReminderWidgetCallToAction() { + onNewMessage( + StudyPlanScreenFeatureMessageNotificationDailyStudyReminderWidgetMessage( + message: NotificationDailyStudyReminderWidgetFeatureMessageWidgetClicked() + ) + ) + } + + func doNotificationDailyStudyReminderWidgetCloseAction() { + onNewMessage( + StudyPlanScreenFeatureMessageNotificationDailyStudyReminderWidgetMessage( + message: NotificationDailyStudyReminderWidgetFeatureMessageCloseClicked() + ) + ) + } + + func doNotificationDailyStudyReminderWidgetRequestNotificationPermission() { + Task(priority: .userInitiated) { + let isGranted = await notificationsRegistrationService.requestAuthorizationIfNeeded() + + await MainActor.run { + onNewMessage( + StudyPlanScreenFeatureMessageNotificationDailyStudyReminderWidgetMessage( + message: NotificationDailyStudyReminderWidgetFeatureMessageNotificationPermissionRequestResult( + isPermissionGranted: isGranted + ) + ) + ) + } + } + } + + func logNotificationDailyStudyReminderWidgetViewedEvent() { + onNewMessage( + StudyPlanScreenFeatureMessageNotificationDailyStudyReminderWidgetMessage( + message: NotificationDailyStudyReminderWidgetFeatureMessageViewedEventMessage() + ) + ) + } + + private func initializeNotificationDailyStudyReminderWidgetFeature() { + Task(priority: .userInitiated) { + let isNotificationPermissionGranted = await NotificationPermissionStatus.current.isRegistered + + await MainActor.run { + onNewMessage( + StudyPlanScreenFeatureMessageNotificationDailyStudyReminderWidgetMessage( + message: NotificationDailyStudyReminderWidgetFeatureMessageInitialize( + isNotificationPermissionGranted: isNotificationPermissionGranted + ) + ) + ) + } + } + } +} + // MARK: - StudyPlanViewModel: StageImplementUnsupportedModalViewControllerDelegate - extension StudyPlanViewModel: StageImplementUnsupportedModalViewControllerDelegate { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift index 448525e9fc..e9c9e4f30f 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift @@ -89,7 +89,7 @@ struct StudyPlanView: View { .padding(.bottom, appearance.trackTitleBottomPadding) } - let usersInterviewWidgetFeatureStateKs = viewModel.usersInterviewWidgetFeatureStateKs + let usersInterviewWidgetFeatureStateKs = viewModel.usersInterviewWidgetStateKs if usersInterviewWidgetFeatureStateKs != .hidden { UsersInterviewWidgetAssembly( stateKs: usersInterviewWidgetFeatureStateKs, @@ -98,6 +98,14 @@ struct StudyPlanView: View { .makeModule() } + if viewModel.notificationDailyStudyReminderWidgetViewStateKs != .hidden { + NotificationDailyStudyReminderWidgetView( + onCallToAction: viewModel.doNotificationDailyStudyReminderWidgetCallToAction, + onClose: viewModel.doNotificationDailyStudyReminderWidgetCloseAction, + onViewedEvent: viewModel.logNotificationDailyStudyReminderWidgetViewedEvent + ) + } + if data.isPaywallBannerShown { StudyPlanPaywallBanner( action: viewModel.doPaywallBannerAction @@ -152,6 +160,10 @@ private extension StudyPlanView { handleUsersInterviewWidgetViewAction( usersInterviewWidgetViewAction.viewAction ) + case .notificationDailyStudyReminderWidgetViewAction(let notificationDailyStudyReminderWidgetViewAction): + handleNotificationDailyStudyReminderWidgetViewAction( + notificationDailyStudyReminderWidgetViewAction.viewAction + ) } } @@ -236,4 +248,13 @@ private extension StudyPlanView { ) } } + + func handleNotificationDailyStudyReminderWidgetViewAction( + _ viewAction: NotificationDailyStudyReminderWidgetFeatureActionViewAction + ) { + switch NotificationDailyStudyReminderWidgetFeatureActionViewActionKs(viewAction) { + case .requestNotificationPermission: + viewModel.doNotificationDailyStudyReminderWidgetRequestNotificationPermission() + } + } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/WelcomeOnboarding/Root/WelcomeOnboardingViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/WelcomeOnboarding/Root/WelcomeOnboardingViewModel.swift index 386ddc0629..1c3cc41814 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/WelcomeOnboarding/Root/WelcomeOnboardingViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/WelcomeOnboarding/Root/WelcomeOnboardingViewModel.swift @@ -18,11 +18,9 @@ final class WelcomeOnboardingViewModel: FeatureViewModel< self.objectWillChangeSubscription = objectWillChange.sink { [weak self] _ in self?.mainScheduler.schedule { [weak self] in - guard let self else { - return + if let strongSelf = self { + strongSelf.viewController?.displayState(strongSelf.state) } - - self.viewController?.displayState(self.state) } } self.onViewAction = { [weak self] viewAction in diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt index 048fbbcaea..8b4c5735b8 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt @@ -43,6 +43,7 @@ enum class HyperskillAnalyticPart(val partName: String) { DAILY_STUDY_REMINDERS_HOUR_INTERVAL_PICKER_MODAL("daily_study_reminders_hour_interval_picker_modal"), REQUEST_REVIEW_MODAL("request_review_modal"), USERS_INTERVIEW_WIDGET("users_interview_widget"), + NOTIFICATION_DAILY_STUDY_REMINDER_WIDGET("notification_daily_study_reminder_widget"), UNSUPPORTED_QUIZ_PLACEHOLDER("unsupported_quiz_placeholder"), CODE_BLANKS("code_blanks"), CODE_BLOCK("code_block"), diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt index 3101e834b8..99b95cc49b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt @@ -124,6 +124,11 @@ sealed class HyperskillAnalyticRoute { override val path: String get() = "${super.path}/users-interview-widget" } + + class NotificationDailyStudyReminderWidget : StudyPlan() { + override val path: String + get() = "${super.path}/notification-daily-study-reminder-widget" + } } class Leaderboard : HyperskillAnalyticRoute() { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt index 8cd7672f2b..31316aa99f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt @@ -35,6 +35,7 @@ import org.hyperskill.app.notification.local.injection.NotificationComponent import org.hyperskill.app.notification.local.injection.NotificationFlowDataComponent import org.hyperskill.app.notification.remote.injection.PlatformPushNotificationsDataComponent import org.hyperskill.app.notification.remote.injection.PushNotificationsComponent +import org.hyperskill.app.notification_daily_study_reminder_widget.injection.NotificationDailyStudyReminderWidgetComponent import org.hyperskill.app.notifications_onboarding.injection.NotificationsOnboardingComponent import org.hyperskill.app.onboarding.injection.OnboardingDataComponent import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource @@ -212,6 +213,7 @@ interface AppGraph { fun buildPaywallComponent(paywallTransitionSource: PaywallTransitionSource): PaywallComponent fun buildManageSubscriptionComponent(): ManageSubscriptionComponent fun buildUsersInterviewWidgetComponent(): UsersInterviewWidgetComponent + fun buildNotificationDailyStudyReminderWidgetComponent(): NotificationDailyStudyReminderWidgetComponent fun buildProblemsLimitInfoModalComponent( params: ProblemsLimitInfoModalFeatureParams ): ProblemsLimitInfoModalComponent diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt index 05915f1fc9..754aea699c 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt @@ -64,6 +64,8 @@ import org.hyperskill.app.notification.local.injection.NotificationFlowDataCompo import org.hyperskill.app.notification.local.injection.NotificationFlowDataComponentImpl import org.hyperskill.app.notification.remote.injection.PushNotificationsComponent import org.hyperskill.app.notification.remote.injection.PushNotificationsComponentImpl +import org.hyperskill.app.notification_daily_study_reminder_widget.injection.NotificationDailyStudyReminderWidgetComponent +import org.hyperskill.app.notification_daily_study_reminder_widget.injection.NotificationDailyStudyReminderWidgetComponentImpl import org.hyperskill.app.notifications_onboarding.injection.NotificationsOnboardingComponent import org.hyperskill.app.notifications_onboarding.injection.NotificationsOnboardingComponentImpl import org.hyperskill.app.onboarding.injection.OnboardingDataComponent @@ -547,6 +549,9 @@ abstract class BaseAppGraph : AppGraph { override fun buildUsersInterviewWidgetComponent(): UsersInterviewWidgetComponent = UsersInterviewWidgetComponentImpl(this) + override fun buildNotificationDailyStudyReminderWidgetComponent(): NotificationDailyStudyReminderWidgetComponent = + NotificationDailyStudyReminderWidgetComponentImpl(this) + override fun buildProblemsLimitInfoModalComponent( params: ProblemsLimitInfoModalFeatureParams ): ProblemsLimitInfoModalComponent = diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/cache/NotificationDailyStudyReminderWidgetCacheDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/cache/NotificationDailyStudyReminderWidgetCacheDataSourceImpl.kt new file mode 100644 index 0000000000..0c71341ee5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/cache/NotificationDailyStudyReminderWidgetCacheDataSourceImpl.kt @@ -0,0 +1,21 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.cache + +import com.russhwolf.settings.Settings +import org.hyperskill.app.notification_daily_study_reminder_widget.data.source.NotificationDailyStudyReminderWidgetCacheDataSource + +internal class NotificationDailyStudyReminderWidgetCacheDataSourceImpl( + private val settings: Settings +) : NotificationDailyStudyReminderWidgetCacheDataSource { + override fun getIsNotificationDailyStudyReminderWidgetHidden(): Boolean = + settings.getBoolean( + NotificationDailyStudyReminderWidgetCacheKeyValues.NOTIFICATION_DAILY_STUDY_REMINDER_WIDGET_HIDDEN, + false + ) + + override fun setIsNotificationDailyStudyReminderWidgetHidden(isHidden: Boolean) { + settings.putBoolean( + NotificationDailyStudyReminderWidgetCacheKeyValues.NOTIFICATION_DAILY_STUDY_REMINDER_WIDGET_HIDDEN, + isHidden + ) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/cache/NotificationDailyStudyReminderWidgetCacheKeyValues.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/cache/NotificationDailyStudyReminderWidgetCacheKeyValues.kt new file mode 100644 index 0000000000..b076cb6d99 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/cache/NotificationDailyStudyReminderWidgetCacheKeyValues.kt @@ -0,0 +1,5 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.cache + +internal object NotificationDailyStudyReminderWidgetCacheKeyValues { + const val NOTIFICATION_DAILY_STUDY_REMINDER_WIDGET_HIDDEN = "notification_daily_study_reminder_widget_hidden" +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/data/repository/NotificationDailyStudyReminderWidgetRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/data/repository/NotificationDailyStudyReminderWidgetRepositoryImpl.kt new file mode 100644 index 0000000000..2d24ce7476 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/data/repository/NotificationDailyStudyReminderWidgetRepositoryImpl.kt @@ -0,0 +1,15 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.data.repository + +import org.hyperskill.app.notification_daily_study_reminder_widget.data.source.NotificationDailyStudyReminderWidgetCacheDataSource +import org.hyperskill.app.notification_daily_study_reminder_widget.domain.repository.NotificationDailyStudyReminderWidgetRepository + +internal class NotificationDailyStudyReminderWidgetRepositoryImpl( + private val notificationDailyStudyReminderWidgetCacheDataSource: NotificationDailyStudyReminderWidgetCacheDataSource +) : NotificationDailyStudyReminderWidgetRepository { + override fun getIsNotificationDailyStudyReminderWidgetHidden(): Boolean = + notificationDailyStudyReminderWidgetCacheDataSource.getIsNotificationDailyStudyReminderWidgetHidden() + + override fun setIsNotificationDailyStudyReminderWidgetHidden(isHidden: Boolean) { + notificationDailyStudyReminderWidgetCacheDataSource.setIsNotificationDailyStudyReminderWidgetHidden(isHidden) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/data/source/NotificationDailyStudyReminderWidgetCacheDataSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/data/source/NotificationDailyStudyReminderWidgetCacheDataSource.kt new file mode 100644 index 0000000000..f7dd750767 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/data/source/NotificationDailyStudyReminderWidgetCacheDataSource.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.data.source + +interface NotificationDailyStudyReminderWidgetCacheDataSource { + fun getIsNotificationDailyStudyReminderWidgetHidden(): Boolean + fun setIsNotificationDailyStudyReminderWidgetHidden(isHidden: Boolean) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/analytic/NotificationDailyStudyReminderWidgetClickedCloseHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/analytic/NotificationDailyStudyReminderWidgetClickedCloseHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..65152276ff --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/analytic/NotificationDailyStudyReminderWidgetClickedCloseHyperskillAnalyticEvent.kt @@ -0,0 +1,29 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents an analytic event for clicking on a close button in the notification daily study reminder widget. + * + * JSON payload: + * ``` + * { + * "route": "/study-plan/notification-daily-study-reminder-widget", + * "action": "click", + * "part": "notification_daily_study_reminder_widget", + * "target": "close" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object NotificationDailyStudyReminderWidgetClickedCloseHyperskillAnalyticEvent : HyperskillAnalyticEvent( + route = HyperskillAnalyticRoute.StudyPlan.NotificationDailyStudyReminderWidget(), + action = HyperskillAnalyticAction.CLICK, + part = HyperskillAnalyticPart.NOTIFICATION_DAILY_STUDY_REMINDER_WIDGET, + target = HyperskillAnalyticTarget.CLOSE +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/analytic/NotificationDailyStudyReminderWidgetClickedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/analytic/NotificationDailyStudyReminderWidgetClickedHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..ef644eaf97 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/analytic/NotificationDailyStudyReminderWidgetClickedHyperskillAnalyticEvent.kt @@ -0,0 +1,26 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute + +/** + * Represents a click analytic event of the notification daily study reminder widget. + * + * JSON payload: + * ``` + * { + * "route": "/study-plan/notification-daily-study-reminder-widget", + * "action": "click", + * "part": "notification_daily_study_reminder_widget" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object NotificationDailyStudyReminderWidgetClickedHyperskillAnalyticEvent : HyperskillAnalyticEvent( + route = HyperskillAnalyticRoute.StudyPlan.NotificationDailyStudyReminderWidget(), + action = HyperskillAnalyticAction.CLICK, + part = HyperskillAnalyticPart.NOTIFICATION_DAILY_STUDY_REMINDER_WIDGET +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/analytic/NotificationDailyStudyReminderWidgetViewedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/analytic/NotificationDailyStudyReminderWidgetViewedHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..3a29d3c48e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/analytic/NotificationDailyStudyReminderWidgetViewedHyperskillAnalyticEvent.kt @@ -0,0 +1,23 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute + +/** + * Represents a view analytic event of the notification daily study reminder widget. + * + * JSON payload: + * ``` + * { + * "route": "/study-plan/notification-daily-study-reminder-widget", + * "action": "view" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object NotificationDailyStudyReminderWidgetViewedHyperskillAnalyticEvent : HyperskillAnalyticEvent( + route = HyperskillAnalyticRoute.StudyPlan.NotificationDailyStudyReminderWidget(), + action = HyperskillAnalyticAction.VIEW +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/repository/NotificationDailyStudyReminderWidgetRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/repository/NotificationDailyStudyReminderWidgetRepository.kt new file mode 100644 index 0000000000..c6efa6af3f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/domain/repository/NotificationDailyStudyReminderWidgetRepository.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.domain.repository + +interface NotificationDailyStudyReminderWidgetRepository { + fun getIsNotificationDailyStudyReminderWidgetHidden(): Boolean + fun setIsNotificationDailyStudyReminderWidgetHidden(isHidden: Boolean) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/injection/NotificationDailyStudyReminderWidgetComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/injection/NotificationDailyStudyReminderWidgetComponent.kt new file mode 100644 index 0000000000..159b3aec80 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/injection/NotificationDailyStudyReminderWidgetComponent.kt @@ -0,0 +1,9 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.injection + +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetActionDispatcher +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetReducer + +interface NotificationDailyStudyReminderWidgetComponent { + val notificationDailyStudyReminderWidgetReducer: NotificationDailyStudyReminderWidgetReducer + val notificationDailyStudyReminderWidgetActionDispatcher: NotificationDailyStudyReminderWidgetActionDispatcher +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/injection/NotificationDailyStudyReminderWidgetComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/injection/NotificationDailyStudyReminderWidgetComponentImpl.kt new file mode 100644 index 0000000000..e392d2801b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/injection/NotificationDailyStudyReminderWidgetComponentImpl.kt @@ -0,0 +1,39 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.injection + +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.notification_daily_study_reminder_widget.cache.NotificationDailyStudyReminderWidgetCacheDataSourceImpl +import org.hyperskill.app.notification_daily_study_reminder_widget.data.repository.NotificationDailyStudyReminderWidgetRepositoryImpl +import org.hyperskill.app.notification_daily_study_reminder_widget.data.source.NotificationDailyStudyReminderWidgetCacheDataSource +import org.hyperskill.app.notification_daily_study_reminder_widget.domain.repository.NotificationDailyStudyReminderWidgetRepository +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.MainNotificationDailyStudyReminderWidgetActionDispatcher +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetActionDispatcher +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetReducer + +internal class NotificationDailyStudyReminderWidgetComponentImpl( + private val appGraph: AppGraph +) : NotificationDailyStudyReminderWidgetComponent { + /* ktlint-disable */ + private val notificationDailyStudyReminderWidgetCacheDataSource: NotificationDailyStudyReminderWidgetCacheDataSource = + NotificationDailyStudyReminderWidgetCacheDataSourceImpl(appGraph.commonComponent.settings) + + private val notificationDailyStudyReminderWidgetRepository: NotificationDailyStudyReminderWidgetRepository = + NotificationDailyStudyReminderWidgetRepositoryImpl( + notificationDailyStudyReminderWidgetCacheDataSource + ) + + override val notificationDailyStudyReminderWidgetReducer: NotificationDailyStudyReminderWidgetReducer + get() = NotificationDailyStudyReminderWidgetReducer() + + /* ktlint-disable */ + override val notificationDailyStudyReminderWidgetActionDispatcher: NotificationDailyStudyReminderWidgetActionDispatcher + get() = NotificationDailyStudyReminderWidgetActionDispatcher( + MainNotificationDailyStudyReminderWidgetActionDispatcher( + config = ActionDispatcherOptions(), + notificationInteractor = appGraph.buildNotificationComponent().notificationInteractor, + notificationDailyStudyReminderWidgetRepository = notificationDailyStudyReminderWidgetRepository, + currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository + ), + analyticInteractor = appGraph.analyticComponent.analyticInteractor + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/MainNotificationDailyStudyReminderWidgetActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/MainNotificationDailyStudyReminderWidgetActionDispatcher.kt new file mode 100644 index 0000000000..217cb88fe6 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/MainNotificationDailyStudyReminderWidgetActionDispatcher.kt @@ -0,0 +1,69 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.presentation + +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.notification.local.domain.interactor.NotificationInteractor +import org.hyperskill.app.notification_daily_study_reminder_widget.domain.repository.NotificationDailyStudyReminderWidgetRepository +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.Action +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.InternalAction +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.InternalMessage +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.Message +import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository +import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher + +internal class MainNotificationDailyStudyReminderWidgetActionDispatcher( + config: ActionDispatcherOptions, + private val notificationInteractor: NotificationInteractor, + private val notificationDailyStudyReminderWidgetRepository: NotificationDailyStudyReminderWidgetRepository, + private val currentProfileStateRepository: CurrentProfileStateRepository +) : CoroutineActionDispatcher(config.createConfig()) { + init { + currentProfileStateRepository.changes + .map { it.gamification.passedTopicsCount } + .distinctUntilChanged() + .onEach { passedTopicsCount -> + onNewMessage( + InternalMessage.PassedTopicsCountChanged( + passedTopicsCount + ) + ) + } + .launchIn(actionScope) + } + + override suspend fun doSuspendableAction(action: Action) { + when (action) { + InternalAction.FetchWidgetData -> + handleFetchWidgetData(::onNewMessage) + InternalAction.HideWidget -> + notificationDailyStudyReminderWidgetRepository.setIsNotificationDailyStudyReminderWidgetHidden(true) + is InternalAction.SaveDailyStudyRemindersIntervalStartHour -> { + notificationInteractor.setDailyStudyRemindersEnabled(enabled = true) + notificationInteractor.setDailyStudyReminderNotificationTime(notificationHour = action.startHour) + } + else -> { + // no op + } + } + } + + private suspend fun handleFetchWidgetData(onNewMessage: (Message) -> Unit) { + val passedTopicsCount = currentProfileStateRepository + .getState(forceUpdate = false) + .map { it.gamification.passedTopicsCount } + .getOrDefault(defaultValue = 0) + + val isWidgetHidden = + notificationDailyStudyReminderWidgetRepository.getIsNotificationDailyStudyReminderWidgetHidden() + + onNewMessage( + InternalMessage.FetchWidgetDataResult( + passedTopicsCount = passedTopicsCount, + isWidgetHidden = isWidgetHidden + ) + ) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/NotificationDailyStudyReminderWidgetActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/NotificationDailyStudyReminderWidgetActionDispatcher.kt new file mode 100644 index 0000000000..652f970a11 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/NotificationDailyStudyReminderWidgetActionDispatcher.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.presentation + +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.analytic.presentation.SingleAnalyticEventActionDispatcher +import org.hyperskill.app.core.presentation.CompositeActionDispatcher +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.Action +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.InternalAction +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.Message + +class NotificationDailyStudyReminderWidgetActionDispatcher internal constructor( + mainNotificationDailyStudyReminderWidgetActionDispatcher: MainNotificationDailyStudyReminderWidgetActionDispatcher, + analyticInteractor: AnalyticInteractor +) : CompositeActionDispatcher( + listOf( + mainNotificationDailyStudyReminderWidgetActionDispatcher, + SingleAnalyticEventActionDispatcher(analyticInteractor) { + (it as? InternalAction.LogAnalyticEvent)?.event + } + ) +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/NotificationDailyStudyReminderWidgetFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/NotificationDailyStudyReminderWidgetFeature.kt new file mode 100644 index 0000000000..629e95ae0e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/NotificationDailyStudyReminderWidgetFeature.kt @@ -0,0 +1,61 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.presentation + +import org.hyperskill.app.analytic.domain.model.AnalyticEvent + +object NotificationDailyStudyReminderWidgetFeature { + sealed interface State { + data object Idle : State + data object Loading : State + data object Hidden : State + data class Data(val passedTopicsCount: Int) : State + } + + sealed interface ViewState { + data object Hidden : ViewState + data object Visible : ViewState + } + + sealed interface Message { + data class Initialize( + val isNotificationPermissionGranted: Boolean + ) : Message + + data object CloseClicked : Message + data object WidgetClicked : Message + + data class NotificationPermissionRequestResult( + val isPermissionGranted: Boolean + ) : Message + + data object ViewedEventMessage : Message + } + + internal sealed interface InternalMessage : Message { + data class FetchWidgetDataResult( + val passedTopicsCount: Int, + val isWidgetHidden: Boolean + ) : InternalMessage + + data class PassedTopicsCountChanged( + val passedTopicsCount: Int + ) : InternalMessage + } + + sealed interface Action { + sealed interface ViewAction : Action { + data object RequestNotificationPermission : ViewAction + } + } + + internal sealed interface InternalAction : Action { + data object FetchWidgetData : InternalAction + + data object HideWidget : InternalAction + + data class SaveDailyStudyRemindersIntervalStartHour( + val startHour: Int + ) : InternalAction + + data class LogAnalyticEvent(val event: AnalyticEvent) : InternalAction + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/NotificationDailyStudyReminderWidgetReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/NotificationDailyStudyReminderWidgetReducer.kt new file mode 100644 index 0000000000..0d44c815be --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/presentation/NotificationDailyStudyReminderWidgetReducer.kt @@ -0,0 +1,116 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.presentation + +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.hyperskill.app.notification_daily_study_reminder_widget.domain.analytic.NotificationDailyStudyReminderWidgetClickedCloseHyperskillAnalyticEvent +import org.hyperskill.app.notification_daily_study_reminder_widget.domain.analytic.NotificationDailyStudyReminderWidgetClickedHyperskillAnalyticEvent +import org.hyperskill.app.notification_daily_study_reminder_widget.domain.analytic.NotificationDailyStudyReminderWidgetViewedHyperskillAnalyticEvent +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.Action +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.InternalAction +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.InternalMessage +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.Message +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.State +import ru.nobird.app.presentation.redux.reducer.StateReducer + +private typealias ReducerResult = Pair> + +class NotificationDailyStudyReminderWidgetReducer : StateReducer { + override fun reduce(state: State, message: Message): ReducerResult = + when (message) { + is Message.Initialize -> handleInitialize(state, message) + is InternalMessage.FetchWidgetDataResult -> handleFetchWidgetDataResult(state, message) + Message.CloseClicked -> handleCloseClicked(state) + Message.WidgetClicked -> handleWidgetClicked(state) + is Message.NotificationPermissionRequestResult -> handleNotificationPermissionRequestResult(state, message) + is InternalMessage.PassedTopicsCountChanged -> handlePassedTopicsCountChanged(state, message) + Message.ViewedEventMessage -> handleViewedEvent(state) + } ?: (state to emptySet()) + + private fun handleInitialize( + state: State, + message: Message.Initialize + ): ReducerResult? = + if (state is State.Idle && !message.isNotificationPermissionGranted) { + State.Loading to setOf(InternalAction.FetchWidgetData) + } else { + null + } + + private fun handleFetchWidgetDataResult( + state: State, + message: InternalMessage.FetchWidgetDataResult + ): ReducerResult? { + if (state !is State.Loading) { + return null + } + + return if (message.isWidgetHidden) { + State.Hidden to emptySet() + } else { + State.Data(passedTopicsCount = message.passedTopicsCount) to emptySet() + } + } + + private fun handleCloseClicked(state: State): ReducerResult? = + if (state is State.Data) { + State.Hidden to setOf( + InternalAction.HideWidget, + InternalAction.LogAnalyticEvent( + NotificationDailyStudyReminderWidgetClickedCloseHyperskillAnalyticEvent + ) + ) + } else { + null + } + + private fun handleWidgetClicked(state: State): ReducerResult? = + if (state is State.Data) { + state to setOf( + Action.ViewAction.RequestNotificationPermission, + InternalAction.LogAnalyticEvent( + NotificationDailyStudyReminderWidgetClickedHyperskillAnalyticEvent + ) + ) + } else { + null + } + + private fun handleNotificationPermissionRequestResult( + state: State, + message: Message.NotificationPermissionRequestResult + ): ReducerResult? = + if (state is State.Data) { + State.Hidden to buildSet { + if (message.isPermissionGranted) { + val dailyStudyRemindersStartHour = Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .hour + add(InternalAction.SaveDailyStudyRemindersIntervalStartHour(dailyStudyRemindersStartHour)) + } + } + } else { + null + } + + private fun handlePassedTopicsCountChanged( + state: State, + message: InternalMessage.PassedTopicsCountChanged + ): ReducerResult? = + if (state is State.Data) { + state.copy(passedTopicsCount = message.passedTopicsCount) to emptySet() + } else { + null + } + + private fun handleViewedEvent(state: State): ReducerResult? = + if (state is State.Data) { + state to setOf( + InternalAction.LogAnalyticEvent( + NotificationDailyStudyReminderWidgetViewedHyperskillAnalyticEvent + ) + ) + } else { + null + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/view/mapper/NotificationDailyStudyReminderWidgetViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/view/mapper/NotificationDailyStudyReminderWidgetViewStateMapper.kt new file mode 100644 index 0000000000..c3cb76178c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification_daily_study_reminder_widget/view/mapper/NotificationDailyStudyReminderWidgetViewStateMapper.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.notification_daily_study_reminder_widget.view.mapper + +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.State +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.ViewState + +internal object NotificationDailyStudyReminderWidgetViewStateMapper { + fun map(state: State): ViewState = + when (state) { + State.Idle, + State.Loading, + State.Hidden -> + ViewState.Hidden + is State.Data -> + if (state.passedTopicsCount > 0) { + ViewState.Visible + } else { + ViewState.Hidden + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenComponentImpl.kt index 9b271148f9..e7af243b53 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenComponentImpl.kt @@ -3,6 +3,7 @@ package org.hyperskill.app.study_plan.screen.injection import org.hyperskill.app.core.injection.AppGraph import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarScreen import org.hyperskill.app.gamification_toolbar.injection.GamificationToolbarComponent +import org.hyperskill.app.notification_daily_study_reminder_widget.injection.NotificationDailyStudyReminderWidgetComponent import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenFeature import org.hyperskill.app.study_plan.widget.injection.StudyPlanWidgetComponent import org.hyperskill.app.users_interview_widget.injection.UsersInterviewWidgetComponent @@ -16,6 +17,9 @@ internal class StudyPlanScreenComponentImpl(private val appGraph: AppGraph) : St private val usersInterviewWidgetComponent: UsersInterviewWidgetComponent = appGraph.buildUsersInterviewWidgetComponent() + private val notificationDailyStudyReminderWidgetComponent: NotificationDailyStudyReminderWidgetComponent = + appGraph.buildNotificationDailyStudyReminderWidgetComponent() + private val studyPlanWidgetComponent: StudyPlanWidgetComponent = appGraph.buildStudyPlanWidgetComponent() @@ -28,6 +32,10 @@ internal class StudyPlanScreenComponentImpl(private val appGraph: AppGraph) : St usersInterviewWidgetReducer = usersInterviewWidgetComponent.usersInterviewWidgetReducer, usersInterviewWidgetActionDispatcher = usersInterviewWidgetComponent .usersInterviewWidgetActionDispatcher, + notificationDailyStudyReminderWidgetReducer = notificationDailyStudyReminderWidgetComponent + .notificationDailyStudyReminderWidgetReducer, + notificationDailyStudyReminderWidgetActionDispatcher = notificationDailyStudyReminderWidgetComponent + .notificationDailyStudyReminderWidgetActionDispatcher, studyPlanWidgetReducer = studyPlanWidgetComponent.studyPlanWidgetReducer, studyPlanWidgetDispatcher = studyPlanWidgetComponent.studyPlanWidgetDispatcher, studyPlanWidgetViewStateMapper = studyPlanWidgetComponent.studyPlanWidgetViewStateMapper, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenFeatureBuilder.kt index 6c9f68b287..e9ea56fe7f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenFeatureBuilder.kt @@ -10,6 +10,9 @@ import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarA import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarReducer import org.hyperskill.app.logging.presentation.wrapWithLogger +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetActionDispatcher +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetReducer import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenFeature import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenFeature.InternalAction import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenReducer @@ -36,6 +39,8 @@ internal object StudyPlanScreenFeatureBuilder { toolbarActionDispatcher: GamificationToolbarActionDispatcher, usersInterviewWidgetReducer: UsersInterviewWidgetReducer, usersInterviewWidgetActionDispatcher: UsersInterviewWidgetActionDispatcher, + notificationDailyStudyReminderWidgetReducer: NotificationDailyStudyReminderWidgetReducer, + notificationDailyStudyReminderWidgetActionDispatcher: NotificationDailyStudyReminderWidgetActionDispatcher, studyPlanWidgetReducer: StudyPlanWidgetReducer, studyPlanWidgetDispatcher: StudyPlanWidgetActionDispatcher, studyPlanWidgetViewStateMapper: StudyPlanWidgetViewStateMapper, @@ -46,6 +51,7 @@ internal object StudyPlanScreenFeatureBuilder { val studyPlanScreenReducer = StudyPlanScreenReducer( toolbarReducer = toolbarReducer, usersInterviewWidgetReducer = usersInterviewWidgetReducer, + notificationDailyStudyReminderWidgetReducer = notificationDailyStudyReminderWidgetReducer, studyPlanWidgetReducer = studyPlanWidgetReducer ).wrapWithLogger(buildVariant, logger, LOG_TAG) @@ -59,6 +65,7 @@ internal object StudyPlanScreenFeatureBuilder { StudyPlanScreenFeature.State( toolbarState = GamificationToolbarFeature.State.Idle, usersInterviewWidgetState = UsersInterviewWidgetFeature.State.Idle, + notificationDailyStudyReminderWidgetState = NotificationDailyStudyReminderWidgetFeature.State.Idle, studyPlanWidgetState = StudyPlanWidgetFeature.State() ), reducer = studyPlanScreenReducer @@ -80,6 +87,14 @@ internal object StudyPlanScreenFeatureBuilder { transformMessage = StudyPlanScreenFeature.Message::UsersInterviewWidgetMessage ) ) + .wrapWithActionDispatcher( + notificationDailyStudyReminderWidgetActionDispatcher.transform( + transformAction = { + it.safeCast()?.action + }, + transformMessage = StudyPlanScreenFeature.Message::NotificationDailyStudyReminderWidgetMessage + ) + ) .wrapWithActionDispatcher( studyPlanWidgetDispatcher.transform( transformAction = { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenFeature.kt index 121b5a0a31..efe62104d4 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenFeature.kt @@ -3,6 +3,7 @@ package org.hyperskill.app.study_plan.screen.presentation import org.hyperskill.app.analytic.domain.model.AnalyticEvent import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature.isRefreshing +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature import org.hyperskill.app.study_plan.widget.view.model.StudyPlanWidgetViewState import org.hyperskill.app.users_interview_widget.presentation.UsersInterviewWidgetFeature @@ -11,8 +12,11 @@ object StudyPlanScreenFeature { internal data class State( val toolbarState: GamificationToolbarFeature.State, val usersInterviewWidgetState: UsersInterviewWidgetFeature.State, + val notificationDailyStudyReminderWidgetState: NotificationDailyStudyReminderWidgetFeature.State, val studyPlanWidgetState: StudyPlanWidgetFeature.State ) { + companion object; + val isRefreshing: Boolean get() = toolbarState.isRefreshing || studyPlanWidgetState.isRefreshing @@ -22,6 +26,7 @@ object StudyPlanScreenFeature { val trackTitle: String?, val toolbarViewState: GamificationToolbarFeature.ViewState, val usersInterviewWidgetState: UsersInterviewWidgetFeature.State, + val notificationDailyStudyReminderWidgetViewState: NotificationDailyStudyReminderWidgetFeature.ViewState, val studyPlanWidgetViewState: StudyPlanWidgetViewState, val isRefreshing: Boolean ) @@ -47,6 +52,10 @@ object StudyPlanScreenFeature { val message: UsersInterviewWidgetFeature.Message ) : Message + data class NotificationDailyStudyReminderWidgetMessage( + val message: NotificationDailyStudyReminderWidgetFeature.Message + ) : Message + data class StudyPlanWidgetMessage( val message: StudyPlanWidgetFeature.Message ) : Message @@ -66,6 +75,10 @@ object StudyPlanScreenFeature { val viewAction: UsersInterviewWidgetFeature.Action.ViewAction ) : ViewAction + data class NotificationDailyStudyReminderWidgetViewAction( + val viewAction: NotificationDailyStudyReminderWidgetFeature.Action.ViewAction + ) : ViewAction + data class StudyPlanWidgetViewAction( val viewAction: StudyPlanWidgetFeature.Action.ViewAction ) : ViewAction @@ -83,6 +96,10 @@ object StudyPlanScreenFeature { val action: UsersInterviewWidgetFeature.Action ) : InternalAction + data class NotificationDailyStudyReminderWidgetAction( + val action: NotificationDailyStudyReminderWidgetFeature.Action + ) : InternalAction + data class StudyPlanWidgetAction( val action: StudyPlanWidgetFeature.Action ) : InternalAction diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt index 560cf7b673..0624a4fbed 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt @@ -2,6 +2,8 @@ package org.hyperskill.app.study_plan.screen.presentation import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarReducer +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetReducer import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedChangeTrackHyperskillAnalyticEvent import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedPullToRefreshHyperskillAnalyticEvent import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedRetryContentLoadingHyperskillAnalyticEvent @@ -17,6 +19,7 @@ internal typealias StudyPlanScreenReducerResult = Pair { override fun reduce( @@ -47,6 +50,16 @@ internal class StudyPlanScreenReducer( reduceUsersInterviewWidgetMessage(state.usersInterviewWidgetState, message.message) state.copy(usersInterviewWidgetState = usersInterviewWidgetState) to usersInterviewWidgetActions } + is StudyPlanScreenFeature.Message.NotificationDailyStudyReminderWidgetMessage -> { + val (notificationDailyStudyReminderWidgetState, notificationDailyStudyReminderWidgetActions) = + reduceNotificationDailyStudyReminderWidgetMessage( + state.notificationDailyStudyReminderWidgetState, + message.message + ) + state.copy( + notificationDailyStudyReminderWidgetState = notificationDailyStudyReminderWidgetState + ) to notificationDailyStudyReminderWidgetActions + } is StudyPlanScreenFeature.Message.StudyPlanWidgetMessage -> { val (widgetState, widgetActions) = reduceStudyPlanWidgetMessage(state.studyPlanWidgetState, message.message) @@ -173,6 +186,26 @@ internal class StudyPlanScreenReducer( return usersInterviewWidgetState to actions } + private fun reduceNotificationDailyStudyReminderWidgetMessage( + state: NotificationDailyStudyReminderWidgetFeature.State, + message: NotificationDailyStudyReminderWidgetFeature.Message + ): Pair> { + val (notificationDailyStudyReminderWidgetState, notificationDailyStudyReminderWidgetActions) = + notificationDailyStudyReminderWidgetReducer.reduce(state, message) + + val actions = notificationDailyStudyReminderWidgetActions + .map { + if (it is NotificationDailyStudyReminderWidgetFeature.Action.ViewAction) { + StudyPlanScreenFeature.Action.ViewAction.NotificationDailyStudyReminderWidgetViewAction(it) + } else { + StudyPlanScreenFeature.InternalAction.NotificationDailyStudyReminderWidgetAction(it) + } + } + .toSet() + + return notificationDailyStudyReminderWidgetState to actions + } + private fun reduceStudyPlanWidgetMessage( state: StudyPlanWidgetFeature.State, message: StudyPlanWidgetFeature.Message diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt index 7224f4ba97..0bde7810e7 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt @@ -3,8 +3,11 @@ package org.hyperskill.app.study_plan.screen.view import org.hyperskill.app.SharedResources import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.gamification_toolbar.view.mapper.GamificationToolbarViewStateMapper +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature +import org.hyperskill.app.notification_daily_study_reminder_widget.view.mapper.NotificationDailyStudyReminderWidgetViewStateMapper import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenFeature import org.hyperskill.app.study_plan.widget.view.mapper.StudyPlanWidgetViewStateMapper +import org.hyperskill.app.users_interview_widget.presentation.UsersInterviewWidgetFeature internal class StudyPlanScreenViewStateMapper( private val studyPlanWidgetViewStateMapper: StudyPlanWidgetViewStateMapper, @@ -15,6 +18,7 @@ internal class StudyPlanScreenViewStateMapper( trackTitle = getTrackTitle(state), toolbarViewState = GamificationToolbarViewStateMapper.map(state.toolbarState), usersInterviewWidgetState = state.usersInterviewWidgetState, + notificationDailyStudyReminderWidgetViewState = getNotificationDailyStudyReminderWidgetViewState(state), studyPlanWidgetViewState = studyPlanWidgetViewStateMapper.map(state.studyPlanWidgetState), isRefreshing = state.isRefreshing ) @@ -28,4 +32,15 @@ internal class StudyPlanScreenViewStateMapper( title ) } + + private fun getNotificationDailyStudyReminderWidgetViewState( + state: StudyPlanScreenFeature.State + ): NotificationDailyStudyReminderWidgetFeature.ViewState = + if (state.usersInterviewWidgetState is UsersInterviewWidgetFeature.State.Visible) { + NotificationDailyStudyReminderWidgetFeature.ViewState.Hidden + } else { + NotificationDailyStudyReminderWidgetViewStateMapper.map( + state.notificationDailyStudyReminderWidgetState + ) + } } \ No newline at end of file diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml index 0d693244f1..e4da7c6570 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -639,6 +639,10 @@ We want to hear your story! Share your Hyperskill app experience in an online feedback session, and receive an Amazon gift card + + Build a habit + Stay on top of your learning with daily reminder notifications + Search Find topic diff --git a/shared/src/commonTest/kotlin/org/hyperskill/notification_daily_study_reminder_widget/NotificationDailyStudyReminderWidgetTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/notification_daily_study_reminder_widget/NotificationDailyStudyReminderWidgetTest.kt new file mode 100644 index 0000000000..88c0c9f980 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/notification_daily_study_reminder_widget/NotificationDailyStudyReminderWidgetTest.kt @@ -0,0 +1,138 @@ +package org.hyperskill.notification_daily_study_reminder_widget + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.hyperskill.app.notification_daily_study_reminder_widget.domain.analytic.NotificationDailyStudyReminderWidgetClickedCloseHyperskillAnalyticEvent +import org.hyperskill.app.notification_daily_study_reminder_widget.domain.analytic.NotificationDailyStudyReminderWidgetClickedHyperskillAnalyticEvent +import org.hyperskill.app.notification_daily_study_reminder_widget.domain.analytic.NotificationDailyStudyReminderWidgetViewedHyperskillAnalyticEvent +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.Action +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.InternalAction +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.InternalMessage +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.Message +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature.State +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetReducer + +class NotificationDailyStudyReminderWidgetTest { + private val reducer = NotificationDailyStudyReminderWidgetReducer() + + @Test + fun `Initialize message when permission not granted should trigger loading state and fetch widget data`() { + val (state, actions) = reducer.reduce( + State.Idle, + Message.Initialize(isNotificationPermissionGranted = false) + ) + + assertEquals(State.Loading, state) + assertContains(actions, InternalAction.FetchWidgetData) + } + + @Test + fun `FetchWidgetDataResult with hidden widget should result in Hidden state`() { + val (state, actions) = reducer.reduce( + State.Loading, + InternalMessage.FetchWidgetDataResult( + passedTopicsCount = 5, + isWidgetHidden = true + ) + ) + + assertEquals(State.Hidden, state) + assertTrue(actions.isEmpty()) + } + + @Test + fun `FetchWidgetDataResult with visible widget should result in Data state`() { + val (state, actions) = reducer.reduce( + State.Loading, + InternalMessage.FetchWidgetDataResult( + passedTopicsCount = 5, + isWidgetHidden = false + ) + ) + + assertEquals(State.Data(passedTopicsCount = 5), state) + assertTrue(actions.isEmpty()) + } + + @Test + fun `CloseClicked message in Data state should hide widget and log analytic event`() { + val (state, actions) = reducer.reduce( + State.Data(passedTopicsCount = 5), + Message.CloseClicked + ) + + assertEquals(State.Hidden, state) + assertContains(actions, InternalAction.HideWidget) + assertTrue { + actions.any { action -> + action is InternalAction.LogAnalyticEvent && + action.event is NotificationDailyStudyReminderWidgetClickedCloseHyperskillAnalyticEvent + } + } + } + + @Test + fun `WidgetClicked message in Data state should request permission and log analytic event`() { + val (state, actions) = reducer.reduce( + State.Data(passedTopicsCount = 5), + Message.WidgetClicked + ) + + assertEquals(State.Data(passedTopicsCount = 5), state) + assertContains(actions, Action.ViewAction.RequestNotificationPermission) + assertTrue { + actions.any { action -> + action is InternalAction.LogAnalyticEvent && + action.event is NotificationDailyStudyReminderWidgetClickedHyperskillAnalyticEvent + } + } + } + + @Test + fun `ViewedEventMessage in Data state should log viewed analytic event`() { + val (state, actions) = reducer.reduce( + State.Data(passedTopicsCount = 5), + Message.ViewedEventMessage + ) + + assertEquals(State.Data(passedTopicsCount = 5), state) + assertTrue { + actions.any { action -> + action is InternalAction.LogAnalyticEvent && + action.event is NotificationDailyStudyReminderWidgetViewedHyperskillAnalyticEvent + } + } + } + + @Test + fun `NotificationPermissionRequestResult when permission granted should save daily reminder interval`() { + val (state, actions) = reducer.reduce( + State.Data(passedTopicsCount = 5), + Message.NotificationPermissionRequestResult( + isPermissionGranted = true + ) + ) + + assertEquals(State.Hidden, state) + assertTrue { + actions.any { action -> + action is InternalAction.SaveDailyStudyRemindersIntervalStartHour + } + } + } + + @Test + fun `PassedTopicsCountChanged should update passed topics count in Data state`() { + val (state, actions) = reducer.reduce( + State.Data(passedTopicsCount = 5), + InternalMessage.PassedTopicsCountChanged( + passedTopicsCount = 10 + ) + ) + + assertEquals(State.Data(passedTopicsCount = 10), state) + assertTrue(actions.isEmpty()) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/notification_daily_study_reminder_widget/NotificationDailyStudyReminderWidgetViewStateMapperTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/notification_daily_study_reminder_widget/NotificationDailyStudyReminderWidgetViewStateMapperTest.kt new file mode 100644 index 0000000000..d8d7cf4ff7 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/notification_daily_study_reminder_widget/NotificationDailyStudyReminderWidgetViewStateMapperTest.kt @@ -0,0 +1,43 @@ +package org.hyperskill.notification_daily_study_reminder_widget + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature +import org.hyperskill.app.notification_daily_study_reminder_widget.view.mapper.NotificationDailyStudyReminderWidgetViewStateMapper + +class NotificationDailyStudyReminderWidgetViewStateMapperTest { + @Test + fun `map Idle state to Hidden ViewState`() { + val state = NotificationDailyStudyReminderWidgetFeature.State.Idle + val viewState = NotificationDailyStudyReminderWidgetViewStateMapper.map(state) + assertEquals(NotificationDailyStudyReminderWidgetFeature.ViewState.Hidden, viewState) + } + + @Test + fun `map Loading state to Hidden ViewState`() { + val state = NotificationDailyStudyReminderWidgetFeature.State.Loading + val viewState = NotificationDailyStudyReminderWidgetViewStateMapper.map(state) + assertEquals(NotificationDailyStudyReminderWidgetFeature.ViewState.Hidden, viewState) + } + + @Test + fun `map Hidden state to Hidden ViewState`() { + val state = NotificationDailyStudyReminderWidgetFeature.State.Hidden + val viewState = NotificationDailyStudyReminderWidgetViewStateMapper.map(state) + assertEquals(NotificationDailyStudyReminderWidgetFeature.ViewState.Hidden, viewState) + } + + @Test + fun `map Data state with passedTopicsCount greater than 0 to Visible ViewState`() { + val state = NotificationDailyStudyReminderWidgetFeature.State.Data(passedTopicsCount = 5) + val viewState = NotificationDailyStudyReminderWidgetViewStateMapper.map(state) + assertEquals(NotificationDailyStudyReminderWidgetFeature.ViewState.Visible, viewState) + } + + @Test + fun `map Data state with passedTopicsCount equals 0 to Hidden ViewState`() { + val state = NotificationDailyStudyReminderWidgetFeature.State.Data(passedTopicsCount = 0) + val viewState = NotificationDailyStudyReminderWidgetViewStateMapper.map(state) + assertEquals(NotificationDailyStudyReminderWidgetFeature.ViewState.Hidden, viewState) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenFeatureStubState.kt b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenFeatureStubState.kt new file mode 100644 index 0000000000..dd184aafea --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenFeatureStubState.kt @@ -0,0 +1,21 @@ +package org.hyperskill.study_plan.screen + +import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature +import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenFeature +import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature +import org.hyperskill.app.users_interview_widget.presentation.UsersInterviewWidgetFeature + +internal fun StudyPlanScreenFeature.State.Companion.stub( + toolbarState: GamificationToolbarFeature.State = GamificationToolbarFeature.State.Idle, + usersInterviewWidgetState: UsersInterviewWidgetFeature.State = UsersInterviewWidgetFeature.State.Idle, + notificationDailyStudyReminderWidgetState: NotificationDailyStudyReminderWidgetFeature.State = + NotificationDailyStudyReminderWidgetFeature.State.Idle, + studyPlanWidgetState: StudyPlanWidgetFeature.State = StudyPlanWidgetFeature.State() +): StudyPlanScreenFeature.State = + StudyPlanScreenFeature.State( + toolbarState = toolbarState, + usersInterviewWidgetState = usersInterviewWidgetState, + notificationDailyStudyReminderWidgetState = notificationDailyStudyReminderWidgetState, + studyPlanWidgetState = studyPlanWidgetState + ) \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt index f1bf734916..30457a59f8 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt @@ -7,6 +7,7 @@ import kotlin.test.fail import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarScreen import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarReducer +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetReducer import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedChangeTrackHyperskillAnalyticEvent import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedPullToRefreshHyperskillAnalyticEvent import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedRetryContentLoadingHyperskillAnalyticEvent @@ -23,13 +24,17 @@ class StudyPlanScreenTest { private val reducer = StudyPlanScreenReducer( GamificationToolbarReducer(GamificationToolbarScreen.STUDY_PLAN), UsersInterviewWidgetReducer(), + NotificationDailyStudyReminderWidgetReducer(), StudyPlanWidgetReducer() ) @Test fun `Viewed event message should trigger logging view analytic event`() { - val (state, actions) = reducer.reduce(stubState(), StudyPlanScreenFeature.Message.ViewedEventMessage) - assertEquals(state, stubState()) + val (state, actions) = reducer.reduce( + StudyPlanScreenFeature.State.stub(), + StudyPlanScreenFeature.Message.ViewedEventMessage + ) + assertEquals(state, StudyPlanScreenFeature.State.stub()) assertEquals(actions.size, 1) val targetAction = actions.first() as StudyPlanScreenFeature.InternalAction.LogAnalyticEvent @@ -42,7 +47,10 @@ class StudyPlanScreenTest { @Test fun `Pull-to-refresh message should trigger logging pull-to-refresh analytic event`() { - val (_, actions) = reducer.reduce(stubState(), StudyPlanScreenFeature.Message.PullToRefresh) + val (_, actions) = reducer.reduce( + StudyPlanScreenFeature.State.stub(), + StudyPlanScreenFeature.Message.PullToRefresh + ) assertTrue { val targetAction = actions .filterIsInstance() @@ -54,13 +62,13 @@ class StudyPlanScreenTest { @Test fun `Retry content loading message should trigger logging analytic event`() { val (actualState, actions) = reducer.reduce( - stubState(), + StudyPlanScreenFeature.State.stub(), StudyPlanScreenFeature.Message.RetryContentLoading ) - val expectedState = stubState( + val expectedState = StudyPlanScreenFeature.State.stub( toolbarState = GamificationToolbarFeature.State.Loading, - questionnaireWidgetState = UsersInterviewWidgetFeature.State.Loading, + usersInterviewWidgetState = UsersInterviewWidgetFeature.State.Loading, studyPlanWidgetState = StudyPlanWidgetFeature.State( sectionsStatus = ContentStatus.LOADING ) @@ -77,11 +85,11 @@ class StudyPlanScreenTest { @Test fun `ChangeTrackButtonClicked message should navigate to track selection screen`() { val (state, actions) = reducer.reduce( - stubState(), + StudyPlanScreenFeature.State.stub(), StudyPlanScreenFeature.Message.ChangeTrackButtonClicked ) - assertEquals(state, stubState()) + assertEquals(state, StudyPlanScreenFeature.State.stub()) assertTrue { actions.any { it is StudyPlanScreenFeature.Action.ViewAction.NavigateTo.TrackSelectionScreen @@ -94,15 +102,4 @@ class StudyPlanScreenTest { } } } - - private fun stubState( - toolbarState: GamificationToolbarFeature.State = GamificationToolbarFeature.State.Idle, - questionnaireWidgetState: UsersInterviewWidgetFeature.State = UsersInterviewWidgetFeature.State.Idle, - studyPlanWidgetState: StudyPlanWidgetFeature.State = StudyPlanWidgetFeature.State() - ): StudyPlanScreenFeature.State = - StudyPlanScreenFeature.State( - toolbarState, - questionnaireWidgetState, - studyPlanWidgetState - ) } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenViewStateMapperTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenViewStateMapperTest.kt new file mode 100644 index 0000000000..81b8e72ce3 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenViewStateMapperTest.kt @@ -0,0 +1,107 @@ +package org.hyperskill.study_plan.screen + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.hyperskill.ResourceProviderStub +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter +import org.hyperskill.app.notification_daily_study_reminder_widget.presentation.NotificationDailyStudyReminderWidgetFeature +import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenFeature +import org.hyperskill.app.study_plan.screen.view.StudyPlanScreenViewStateMapper +import org.hyperskill.app.study_plan.widget.view.mapper.StudyPlanWidgetViewStateMapper +import org.hyperskill.app.users_interview_widget.presentation.UsersInterviewWidgetFeature + +class StudyPlanScreenViewStateMapperTest { + private val viewStateMapper = StudyPlanScreenViewStateMapper( + studyPlanWidgetViewStateMapper = StudyPlanWidgetViewStateMapper( + dateFormatter = SharedDateFormatter( + ResourceProviderStub() + ) + ), + resourceProvider = ResourceProviderStub() + ) + + @Test + fun `NotificationDailyStudyReminderWidgetViewState should be Hidden when UsersInterviewWidget is Visible`() { + val state = StudyPlanScreenFeature.State.stub( + usersInterviewWidgetState = UsersInterviewWidgetFeature.State.Visible, + notificationDailyStudyReminderWidgetState = NotificationDailyStudyReminderWidgetFeature.State.Data( + passedTopicsCount = 5 + ) + ) + + val viewState = viewStateMapper.map(state) + + assertEquals( + NotificationDailyStudyReminderWidgetFeature.ViewState.Hidden, + viewState.notificationDailyStudyReminderWidgetViewState + ) + } + + /* ktlint-disable */ + @Test + fun `NotificationDailyStudyReminderWidgetViewState should be Visible when Data state has passedTopicsCount greater than 0 and UsersInterviewWidget is not Visible`() { + val state = StudyPlanScreenFeature.State.stub( + usersInterviewWidgetState = UsersInterviewWidgetFeature.State.Idle, + notificationDailyStudyReminderWidgetState = NotificationDailyStudyReminderWidgetFeature.State.Data( + passedTopicsCount = 5 + ) + ) + + val viewState = viewStateMapper.map(state) + + assertEquals( + NotificationDailyStudyReminderWidgetFeature.ViewState.Visible, + viewState.notificationDailyStudyReminderWidgetViewState + ) + } + + /* ktlint-disable */ + @Test + fun `NotificationDailyStudyReminderWidgetViewState should be Hidden when Data state has passedTopicsCount equals 0 and UsersInterviewWidget is not Visible`() { + val state = StudyPlanScreenFeature.State.stub( + usersInterviewWidgetState = UsersInterviewWidgetFeature.State.Idle, + notificationDailyStudyReminderWidgetState = NotificationDailyStudyReminderWidgetFeature.State.Data( + passedTopicsCount = 0 + ) + ) + + val viewState = viewStateMapper.map(state) + + assertEquals( + NotificationDailyStudyReminderWidgetFeature.ViewState.Hidden, + viewState.notificationDailyStudyReminderWidgetViewState + ) + } + + /* ktlint-disable */ + @Test + fun `NotificationDailyStudyReminderWidgetViewState should be Hidden when State is Idle and UsersInterviewWidget is not Visible`() { + val state = StudyPlanScreenFeature.State.stub( + usersInterviewWidgetState = UsersInterviewWidgetFeature.State.Idle, + notificationDailyStudyReminderWidgetState = NotificationDailyStudyReminderWidgetFeature.State.Idle + ) + + val viewState = viewStateMapper.map(state) + + assertEquals( + NotificationDailyStudyReminderWidgetFeature.ViewState.Hidden, + viewState.notificationDailyStudyReminderWidgetViewState + ) + } + + /* ktlint-disable */ + @Test + fun `NotificationDailyStudyReminderWidgetViewState should be Hidden when State is Loading and UsersInterviewWidget is not Visible`() { + val state = StudyPlanScreenFeature.State.stub( + usersInterviewWidgetState = UsersInterviewWidgetFeature.State.Idle, + notificationDailyStudyReminderWidgetState = NotificationDailyStudyReminderWidgetFeature.State.Loading + ) + + val viewState = viewStateMapper.map(state) + + assertEquals( + NotificationDailyStudyReminderWidgetFeature.ViewState.Hidden, + viewState.notificationDailyStudyReminderWidgetViewState + ) + } +} \ No newline at end of file